From ee5de6563abdc5c6e12c845b1f186084e39165b8 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Fri, 27 Sep 2024 11:47:01 +0200 Subject: [PATCH] fix: add pat endpoints --- .../user/v3alpha/integration_test/pat_test.go | 440 ++++++++++++++++++ .../api/grpc/resources/user/v3alpha/pat.go | 57 +++ .../grpc/resources/user/v3alpha/publickey.go | 12 +- internal/command/user_v3_pat.go | 103 ++++ internal/command/user_v3_pat_model.go | 126 +++++ internal/command/user_v3_pat_test.go | 404 ++++++++++++++++ internal/integration/client.go | 20 + .../user/v3alpha/authenticator.proto | 29 +- .../resources/user/v3alpha/user_service.proto | 125 ++++- 9 files changed, 1306 insertions(+), 10 deletions(-) create mode 100644 internal/api/grpc/resources/user/v3alpha/integration_test/pat_test.go create mode 100644 internal/api/grpc/resources/user/v3alpha/pat.go create mode 100644 internal/command/user_v3_pat.go create mode 100644 internal/command/user_v3_pat_model.go create mode 100644 internal/command/user_v3_pat_test.go diff --git a/internal/api/grpc/resources/user/v3alpha/integration_test/pat_test.go b/internal/api/grpc/resources/user/v3alpha/integration_test/pat_test.go new file mode 100644 index 00000000000..787641a81f9 --- /dev/null +++ b/internal/api/grpc/resources/user/v3alpha/integration_test/pat_test.go @@ -0,0 +1,440 @@ +//go:build integration + +package user_test + +import ( + "context" + "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + object "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" + resource_object "github.com/zitadel/zitadel/pkg/grpc/resources/object/v3alpha" + user "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" +) + +func TestServer_AddPersonalAccessToken(t *testing.T) { + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + schema := []byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + + type res struct { + want *resource_object.Details + } + tests := []struct { + name string + ctx context.Context + dep func(req *user.AddPersonalAccessTokenRequest) error + req *user.AddPersonalAccessTokenRequest + res res + wantErr bool + }{ + { + name: "pat add, no context", + ctx: context.Background(), + dep: func(req *user.AddPersonalAccessTokenRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.AddPersonalAccessTokenRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + PersonalAccessToken: &user.SetPersonalAccessToken{}, + }, + wantErr: true, + }, + { + name: "pat add, no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.AddPersonalAccessTokenRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.AddPersonalAccessTokenRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + PersonalAccessToken: &user.SetPersonalAccessToken{}, + }, + wantErr: true, + }, + { + name: "pat add, pat empty", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.AddPersonalAccessTokenRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.AddPersonalAccessTokenRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + PersonalAccessToken: &user.SetPersonalAccessToken{}, + }, + wantErr: true, + }, + { + name: "pat add, user not existing in org", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.AddPersonalAccessTokenRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.AddPersonalAccessTokenRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: "notexisting", + }, + }, + PersonalAccessToken: &user.SetPersonalAccessToken{}, + }, + wantErr: true, + }, + { + name: "pat add, user not existing", + ctx: isolatedIAMOwnerCTX, + req: &user.AddPersonalAccessTokenRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "notexisting", + PersonalAccessToken: &user.SetPersonalAccessToken{}, + }, + wantErr: true, + }, + { + name: "pat add, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.AddPersonalAccessTokenRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.AddPersonalAccessTokenRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + PersonalAccessToken: &user.SetPersonalAccessToken{}, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "pat add, no org, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.AddPersonalAccessTokenRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.AddPersonalAccessTokenRequest{ + PersonalAccessToken: &user.SetPersonalAccessToken{}, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "pat add, expirationdate, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.AddPersonalAccessTokenRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.AddPersonalAccessTokenRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + PersonalAccessToken: &user.SetPersonalAccessToken{}, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "pat add, generated, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.AddPersonalAccessTokenRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.AddPersonalAccessTokenRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + PersonalAccessToken: &user.SetPersonalAccessToken{}, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + err := tt.dep(tt.req) + assert.NoError(t, err) + } + got, err := instance.Client.UserV3Alpha.AddPersonalAccessToken(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + integration.AssertResourceDetails(t, tt.res.want, got.Details) + }) + } +} + +func TestServer_DeletePersonalAccessToken(t *testing.T) { + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + schema := []byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + + type res struct { + want *resource_object.Details + } + tests := []struct { + name string + ctx context.Context + dep func(req *user.RemovePersonalAccessTokenRequest) error + req *user.RemovePersonalAccessTokenRequest + res res + wantErr bool + }{ + { + name: "pat delete, no context", + ctx: context.Background(), + dep: func(req *user.RemovePersonalAccessTokenRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + patResp := instance.AddAuthenticatorPersonalAccessToken(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), userResp.GetDetails().GetId()) + req.PersonalAccessTokenId = patResp.GetPersonalAccessTokenId() + return nil + }, + req: &user.RemovePersonalAccessTokenRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "pat delete, no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.RemovePersonalAccessTokenRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + patResp := instance.AddAuthenticatorPersonalAccessToken(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), userResp.GetDetails().GetId()) + req.PersonalAccessTokenId = patResp.GetPersonalAccessTokenId() + return nil + }, + req: &user.RemovePersonalAccessTokenRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "pat remove, id empty", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + req: &user.RemovePersonalAccessTokenRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + PersonalAccessTokenId: "notempty", + }, + wantErr: true, + }, + { + name: "pat delete, userid empty", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + req: &user.RemovePersonalAccessTokenRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "notempty", + }, + wantErr: true, + }, + { + name: "pat remove, not existing", + ctx: isolatedIAMOwnerCTX, + req: &user.RemovePersonalAccessTokenRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + PersonalAccessTokenId: "notempty", + Id: "notexisting", + }, + wantErr: true, + }, + { + name: "pat remove, no pat", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.RemovePersonalAccessTokenRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.RemovePersonalAccessTokenRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "pat remove, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.RemovePersonalAccessTokenRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + patResp := instance.AddAuthenticatorPersonalAccessToken(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), userResp.GetDetails().GetId()) + req.PersonalAccessTokenId = patResp.GetPersonalAccessTokenId() + return nil + }, + req: &user.RemovePersonalAccessTokenRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "pat remove, already removed", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.RemovePersonalAccessTokenRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + resp := instance.AddAuthenticatorPersonalAccessToken(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id) + req.PersonalAccessTokenId = resp.GetPersonalAccessTokenId() + instance.RemoveAuthenticatorPersonalAccessToken(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, req.PersonalAccessTokenId) + return nil + }, + req: &user.RemovePersonalAccessTokenRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + err := tt.dep(tt.req) + assert.NoError(t, err) + } + got, err := instance.Client.UserV3Alpha.RemovePersonalAccessToken(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + + } + assert.NoError(t, err) + integration.AssertResourceDetails(t, tt.res.want, got.Details) + + }) + } +} diff --git a/internal/api/grpc/resources/user/v3alpha/pat.go b/internal/api/grpc/resources/user/v3alpha/pat.go new file mode 100644 index 00000000000..36eb890be24 --- /dev/null +++ b/internal/api/grpc/resources/user/v3alpha/pat.go @@ -0,0 +1,57 @@ +package user + +import ( + "context" + "time" + + "github.com/zitadel/oidc/v3/pkg/oidc" + + resource_object "github.com/zitadel/zitadel/internal/api/grpc/resources/object/v3alpha" + z_oidc "github.com/zitadel/zitadel/internal/api/oidc" + "github.com/zitadel/zitadel/internal/command" + object "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" + user "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" +) + +func (s *Server) AddPersonalAccessToken(ctx context.Context, req *user.AddPersonalAccessTokenRequest) (_ *user.AddPersonalAccessTokenResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + pat := addPersonalAccessTokenRequestToAddPAT(req) + details, err := s.command.AddPAT(ctx, pat) + if err != nil { + return nil, err + } + return &user.AddPersonalAccessTokenResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + PersonalAccessTokenId: details.ID, + PersonalAccessToken: pat.Token, + }, nil +} + +func addPersonalAccessTokenRequestToAddPAT(req *user.AddPersonalAccessTokenRequest) *command.AddPAT { + expDate := time.Time{} + if req.GetPersonalAccessToken().GetExpirationDate() != nil { + expDate = req.GetPersonalAccessToken().GetExpirationDate().AsTime() + } + + return &command.AddPAT{ + ResourceOwner: organizationToUpdateResourceOwner(req.Organization), + UserID: req.GetId(), + ExpirationDate: expDate, + Scope: []string{oidc.ScopeOpenID, oidc.ScopeProfile, z_oidc.ScopeUserMetaData, z_oidc.ScopeResourceOwner}, + } +} + +func (s *Server) RemovePersonalAccessToken(ctx context.Context, req *user.RemovePersonalAccessTokenRequest) (_ *user.RemovePersonalAccessTokenResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + details, err := s.command.DeletePAT(ctx, organizationToUpdateResourceOwner(req.Organization), req.GetId(), req.GetPersonalAccessTokenId()) + if err != nil { + return nil, err + } + return &user.RemovePersonalAccessTokenResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + }, nil +} diff --git a/internal/api/grpc/resources/user/v3alpha/publickey.go b/internal/api/grpc/resources/user/v3alpha/publickey.go index 4a3fd896088..bf07f298a33 100644 --- a/internal/api/grpc/resources/user/v3alpha/publickey.go +++ b/internal/api/grpc/resources/user/v3alpha/publickey.go @@ -2,6 +2,7 @@ package user import ( "context" + "time" resource_object "github.com/zitadel/zitadel/internal/api/grpc/resources/object/v3alpha" "github.com/zitadel/zitadel/internal/command" @@ -26,10 +27,15 @@ func (s *Server) AddPublicKey(ctx context.Context, req *user.AddPublicKeyRequest } func addPublicKeyRequestToAddPublicKey(req *user.AddPublicKeyRequest) *command.AddPublicKey { + expDate := time.Time{} + if req.GetPublicKey().GetExpirationDate() != nil { + expDate = req.GetPublicKey().GetExpirationDate().AsTime() + } return &command.AddPublicKey{ - ResourceOwner: organizationToUpdateResourceOwner(req.Organization), - UserID: req.GetId(), - PublicKey: req.GetPublicKey().GetPublicKey().GetPublicKey(), + ResourceOwner: organizationToUpdateResourceOwner(req.Organization), + UserID: req.GetId(), + PublicKey: req.GetPublicKey().GetPublicKey().GetPublicKey(), + ExpirationDate: expDate, } } diff --git a/internal/command/user_v3_pat.go b/internal/command/user_v3_pat.go new file mode 100644 index 00000000000..344130879e4 --- /dev/null +++ b/internal/command/user_v3_pat.go @@ -0,0 +1,103 @@ +package command + +import ( + "context" + "encoding/base64" + "time" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + PATPrefix = "pat_" +) + +type AddPAT struct { + ResourceOwner string + UserID string + + ExpirationDate time.Time + Scope []string + Token string +} + +func (wm *AddPAT) GetExpirationDate() time.Time { + return wm.ExpirationDate +} + +func (wm *AddPAT) SetExpirationDate(date time.Time) { + wm.ExpirationDate = date +} + +func (c *Commands) AddPAT(ctx context.Context, pat *AddPAT) (*domain.ObjectDetails, error) { + if pat.UserID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-14sGR7lTaj", "Errors.IDMissing") + } + schemauser, err := existingSchemaUser(ctx, c, pat.ResourceOwner, pat.UserID) + if err != nil { + return nil, err + } + + _, err = existingSchema(ctx, c, "", schemauser.SchemaID) + if err != nil { + return nil, err + } + // TODO check for possible authenticators + + id, err := c.idGenerator.Next() + if err != nil { + return nil, err + } + writeModel, err := c.getSchemaPATWM(ctx, schemauser.ResourceOwner, schemauser.AggregateID, id) + if err != nil { + return nil, err + } + + events, err := writeModel.NewCreate(ctx, pat.ExpirationDate, pat.Scope) + if err != nil { + return nil, err + } + pat.Token, err = createSchemaUserPAT(c.keyAlgorithm, writeModel.AggregateID, pat.UserID) + if err != nil { + return nil, err + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} + +func (c *Commands) DeletePAT(ctx context.Context, resourceOwner, userID, id string) (*domain.ObjectDetails, error) { + if userID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-hzqeAXW1qP", "Errors.IDMissing") + } + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-BNNYJz6Yxt", "Errors.IDMissing") + } + + writeModel, err := c.getSchemaPATWM(ctx, resourceOwner, userID, id) + if err != nil { + return nil, err + } + + events, err := writeModel.NewDelete(ctx) + if err != nil { + return nil, err + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} + +func (c *Commands) getSchemaPATWM(ctx context.Context, resourceOwner, userID, id string) (*PATV3WriteModel, error) { + writeModel := NewPATV3WriteModel(resourceOwner, userID, id, c.checkPermission) + if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { + return nil, err + } + return writeModel, nil +} + +func createSchemaUserPAT(algorithm crypto.EncryptionAlgorithm, tokenID, userID string) (string, error) { + encrypted, err := algorithm.Encrypt([]byte(tokenID + ":" + userID)) + if err != nil { + return "", err + } + return PATPrefix + base64.RawURLEncoding.EncodeToString(encrypted), nil +} diff --git a/internal/command/user_v3_pat_model.go b/internal/command/user_v3_pat_model.go new file mode 100644 index 00000000000..77048100b71 --- /dev/null +++ b/internal/command/user_v3_pat_model.go @@ -0,0 +1,126 @@ +package command + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user/authenticator" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type PATV3WriteModel struct { + eventstore.WriteModel + UserID string + ExpirationDate time.Time + Scopes []string + + checkPermission domain.PermissionCheck +} + +func (wm *PATV3WriteModel) GetWriteModel() *eventstore.WriteModel { + return &wm.WriteModel +} + +func NewPATV3WriteModel(resourceOwner, userID, id string, checkPermission domain.PermissionCheck) *PATV3WriteModel { + return &PATV3WriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: id, + ResourceOwner: resourceOwner, + }, + UserID: userID, + checkPermission: checkPermission, + } +} + +func (wm *PATV3WriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *authenticator.PATCreatedEvent: + if e.UserID != wm.UserID { + continue + } + wm.UserID = e.UserID + wm.Scopes = e.Scopes + wm.ExpirationDate = e.ExpirationDate + case *authenticator.PATDeletedEvent: + wm.UserID = "" + wm.Scopes = nil + wm.ExpirationDate = time.Time{} + } + } + return wm.WriteModel.Reduce() +} + +func (wm *PATV3WriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + ResourceOwner(wm.ResourceOwner). + AddQuery(). + AggregateTypes(authenticator.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes( + authenticator.PATCreatedType, + authenticator.PATDeletedType, + ).Builder() +} + +func (wm *PATV3WriteModel) checkPermissionWrite(ctx context.Context) error { + if wm.UserID == authz.GetCtxData(ctx).UserID { + return nil + } + if err := wm.checkPermission(ctx, domain.PermissionUserWrite, wm.ResourceOwner, wm.UserID); err != nil { + return err + } + return nil +} + +func (wm *PATV3WriteModel) NewCreate( + ctx context.Context, + expirationDate time.Time, + scopes []string, +) ([]eventstore.Command, error) { + if err := wm.NotExists(); err != nil { + return nil, err + } + if err := wm.checkPermissionWrite(ctx); err != nil { + return nil, err + } + return []eventstore.Command{ + authenticator.NewPATCreatedEvent(ctx, + AuthenticatorAggregateFromWriteModel(wm.GetWriteModel()), + wm.UserID, + expirationDate, + scopes, + ), + }, nil +} + +func (wm *PATV3WriteModel) NewDelete(ctx context.Context) ([]eventstore.Command, error) { + if err := wm.Exists(); err != nil { + return nil, err + } + if err := wm.checkPermissionWrite(ctx); err != nil { + return nil, err + } + return []eventstore.Command{ + authenticator.NewPATDeletedEvent(ctx, + AuthenticatorAggregateFromWriteModel(wm.GetWriteModel()), + ), + }, nil +} + +func (wm *PATV3WriteModel) Exists() error { + if len(wm.Scopes) == 0 { + return zerrors.ThrowNotFound(nil, "TODO", "TODO") + } + return nil +} + +func (wm *PATV3WriteModel) NotExists() error { + if err := wm.Exists(); err != nil { + return nil + } + return zerrors.ThrowAlreadyExists(nil, "TODO", "TODO") +} diff --git a/internal/command/user_v3_pat_test.go b/internal/command/user_v3_pat_test.go new file mode 100644 index 00000000000..2ab2ee1897e --- /dev/null +++ b/internal/command/user_v3_pat_test.go @@ -0,0 +1,404 @@ +package command + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/id" + "github.com/zitadel/zitadel/internal/id/mock" + "github.com/zitadel/zitadel/internal/repository/user/authenticator" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func filterPATExisting() expect { + return expectFilter( + eventFromEventPusher( + authenticator.NewPATCreatedEvent( + context.Background(), + &authenticator.NewAggregate("pk1", "org1").Aggregate, + "user1", + time.Time{}, + []string{"first", "second", "third"}, + ), + ), + ) +} + +func TestCommands_AddPAT(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator id.Generator + checkPermission domain.PermissionCheck + tokenAlg crypto.EncryptionAlgorithm + } + type args struct { + ctx context.Context + user *AddPAT + } + type res struct { + details *domain.ObjectDetails + token string + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &AddPAT{}, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-14sGR7lTaj", "Errors.IDMissing")) + }, + }, + }, + { + "user not existing, error", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &AddPAT{ + UserID: "notexisting", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-syHyCsGmvM", "Errors.User.NotFound")) + }, + }, + }, + { + "no permission, error", + fields{ + eventstore: expectEventstore( + filterSchemaUserExisting(), + filterSchemaExisting(), + expectFilter(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + idGenerator: mock.ExpectID(t, "pk1"), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &AddPAT{ + UserID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "userschema not existing, error", + fields{ + eventstore: expectEventstore( + filterSchemaUserExisting(), + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &AddPAT{ + UserID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-VLDTtxT3If", "Errors.UserSchema.NotExists")) + }, + }, + }, + { + "pat added, ok", + fields{ + eventstore: expectEventstore( + filterSchemaUserExisting(), + filterSchemaExisting(), + expectFilter(), + expectPush( + authenticator.NewPATCreatedEvent( + context.Background(), + &authenticator.NewAggregate("pk1", "org1").Aggregate, + "user1", + time.Time{}, + []string{"first", "second", "third"}, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: mock.ExpectID(t, "pk1"), + tokenAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &AddPAT{ + UserID: "user1", + Scope: []string{"first", "second", "third"}, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + token: "pat_cGsxOnVzZXIx", + }, + }, + { + "pat added, expirationdate, ok", + fields{ + eventstore: expectEventstore( + filterSchemaUserExisting(), + filterSchemaExisting(), + expectFilter(), + expectPush( + authenticator.NewPATCreatedEvent( + context.Background(), + &authenticator.NewAggregate("pk1", "org1").Aggregate, + "user1", + time.Date(2024, time.January, 1, 1, 1, 1, 1, time.UTC), + []string{"first", "second", "third"}, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: mock.ExpectID(t, "pk1"), + tokenAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &AddPAT{ + UserID: "user1", + ExpirationDate: time.Date(2024, time.January, 1, 1, 1, 1, 1, time.UTC), + Scope: []string{"first", "second", "third"}, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + token: "pat_cGsxOnVzZXIx", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + checkPermission: tt.fields.checkPermission, + keyAlgorithm: tt.fields.tokenAlg, + } + details, err := c.AddPAT(tt.args.ctx, tt.args.user) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.details, details) + assert.Equal(t, tt.res.token, tt.args.user.Token) + } + }) + } +} + +func TestCommands_DeletePAT(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + ctx context.Context + resourceOwner string + userID string + id string + } + type res struct { + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-hzqeAXW1qP", "Errors.IDMissing")) + }, + }, + }, + { + "no ID, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userID: "user1", + id: "", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-BNNYJz6Yxt", "Errors.IDMissing")) + }, + }, + }, + { + "pk not existing, error", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userID: "user1", + id: "notexisting", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "TODO", "TODO")) + }, + }, + }, + { + "pk already removed, error", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + authenticator.NewPATCreatedEvent( + context.Background(), + &authenticator.NewAggregate("pk1", "org1").Aggregate, + "user1", + time.Time{}, + []string{"first", "second", "third"}, + ), + ), + eventFromEventPusher( + authenticator.NewPATDeletedEvent( + context.Background(), + &authenticator.NewAggregate("pk1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userID: "user1", + id: "notexisting", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "TODO", "TODO")) + }, + }, + }, + { + "no permission, error", + fields{ + eventstore: expectEventstore( + filterPATExisting(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userID: "user1", + id: "pk1", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "pk removed, ok", + fields{ + eventstore: expectEventstore( + filterPATExisting(), + expectPush( + authenticator.NewPATDeletedEvent( + context.Background(), + &authenticator.NewAggregate("pk1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + userID: "user1", + id: "pk1", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + } + details, err := c.DeletePAT(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.id) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.details, details) + } + }) + } +} diff --git a/internal/integration/client.go b/internal/integration/client.go index 9fca5731d2a..e4843a31117 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -854,6 +854,26 @@ func (i *Instance) RemoveAuthenticatorPublicKey(ctx context.Context, orgID strin return user } +func (i *Instance) AddAuthenticatorPersonalAccessToken(ctx context.Context, orgID string, userID string) *user_v3alpha.AddPersonalAccessTokenResponse { + user, err := i.Client.UserV3Alpha.AddPersonalAccessToken(ctx, &user_v3alpha.AddPersonalAccessTokenRequest{ + Organization: &object_v3alpha.Organization{Property: &object_v3alpha.Organization_OrgId{OrgId: orgID}}, + Id: userID, + PersonalAccessToken: &user_v3alpha.SetPersonalAccessToken{}, + }) + logging.OnError(err).Fatal("create pat") + return user +} + +func (i *Instance) RemoveAuthenticatorPersonalAccessToken(ctx context.Context, orgID string, userid, id string) *user_v3alpha.RemovePersonalAccessTokenResponse { + user, err := i.Client.UserV3Alpha.RemovePersonalAccessToken(ctx, &user_v3alpha.RemovePersonalAccessTokenRequest{ + Organization: &object_v3alpha.Organization{Property: &object_v3alpha.Organization_OrgId{OrgId: orgID}}, + Id: userid, + PersonalAccessTokenId: id, + }) + logging.OnError(err).Fatal("remove pat") + return user +} + func (i *Instance) UpdateSchemaUserEmail(ctx context.Context, orgID string, userID string, email string) *user_v3alpha.SetContactEmailResponse { user, err := i.Client.UserV3Alpha.SetContactEmail(ctx, &user_v3alpha.SetContactEmailRequest{ Organization: &object_v3alpha.Organization{Property: &object_v3alpha.Organization_OrgId{OrgId: orgID}}, diff --git a/proto/zitadel/resources/user/v3alpha/authenticator.proto b/proto/zitadel/resources/user/v3alpha/authenticator.proto index caa9f139b60..ccc68b75ae5 100644 --- a/proto/zitadel/resources/user/v3alpha/authenticator.proto +++ b/proto/zitadel/resources/user/v3alpha/authenticator.proto @@ -29,6 +29,8 @@ message Authenticators { repeated PublicKey public_keys = 7; // A list of the user's linked identity providers (IDPs). repeated IdentityProvider identity_providers = 8; + // A list of the user's personal access tokens. + repeated PersonalAccessToken personal_access_tokens = 9; } message Username { @@ -236,10 +238,27 @@ message IdentityProvider { ]; } +message PersonalAccessToken { + // ID is the read-only unique identifier of the personal access token. + string personal_access_token_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\""; + } + ]; + zitadel.resources.object.v3alpha.Details details = 2; + // After the expiration date, the key will no longer be usable for authentication. + google.protobuf.Timestamp expiration_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"3019-04-01T08:45:00.000000Z\""; + } + ]; +} + message SetAuthenticators { repeated SetUsername usernames = 1; SetPassword password = 2; - SetPublicKey public_key = 3; + repeated SetPublicKey public_key = 3; + repeated SetPersonalAccessToken personal_access_token = 4; } message SetUsername { @@ -347,6 +366,14 @@ message ProvidedPublicKey { ]; } +message SetPersonalAccessToken { + // After the expiration date, the key will no longer be usable for authentication. + optional google.protobuf.Timestamp expiration_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"3019-04-01T08:45:00.000000Z\""; + } + ]; +} message SendPasswordResetEmail { // Optionally set a url_template, which will be used in the password reset mail diff --git a/proto/zitadel/resources/user/v3alpha/user_service.proto b/proto/zitadel/resources/user/v3alpha/user_service.proto index 743eebf2dec..32a3bd01408 100644 --- a/proto/zitadel/resources/user/v3alpha/user_service.proto +++ b/proto/zitadel/resources/user/v3alpha/user_service.proto @@ -642,15 +642,13 @@ service ZITADELUsers { }; } - - // Add a public key // // Add a new public key to a user. The public key will be used to identify the user on authentication. rpc AddPublicKey (AddPublicKeyRequest) returns (AddPublicKeyResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{id}/publickey" - body: "publickey" + post: "/resources/v3alpha/users/{id}/public_key" + body: "public_key" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -674,7 +672,56 @@ service ZITADELUsers { // Remove an existing public key of a user, so it cannot be used for authentication anymore. rpc RemovePublicKey (RemovePublicKeyRequest) returns (RemovePublicKeyResponse) { option (google.api.http) = { - delete: "/resources/v3alpha/users/{id}/publickey/{publickey_id}" + delete: "/resources/v3alpha/users/{id}/public_key/{public_key_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "Username successfully removed"; + } + }; + }; + } + + // Add a personal access token + // + // Add a new personal access token to a user. The personal access token will be used to identify the user on authentication. + rpc AddPersonalAccessToken (AddPersonalAccessTokenRequest) returns (AddPersonalAccessTokenResponse) { + option (google.api.http) = { + post: "/resources/v3alpha/users/{id}/personal_access_token" + body: "personal_access_token" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "Username successfully added"; + } + }; + }; + } + + // Remove a personal access token + // + // Remove an existing personal access token of a user, so it cannot be used for authentication anymore. + rpc RemovePersonalAccessToken (RemovePersonalAccessTokenRequest) returns (RemovePersonalAccessTokenResponse) { + option (google.api.http) = { + delete: "/resources/v3alpha/users/{id}/personal_access_token/{personal_access_token_id}" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -1696,7 +1743,6 @@ message RemovePasswordResponse { zitadel.resources.object.v3alpha.Details details = 1; } - message AddPublicKeyRequest { optional zitadel.object.v3alpha.Instance instance = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1764,6 +1810,73 @@ message RemovePublicKeyResponse { zitadel.resources.object.v3alpha.Details details = 1; } +message AddPersonalAccessTokenRequest { + optional zitadel.object.v3alpha.Instance instance = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"domain from HOST or :authority header\"" + } + ]; + // Optionally expect the user to be in this organization. + optional zitadel.object.v3alpha.Organization organization = 2; + // unique identifier of the user. + string id = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; + // Set the user's new personal access token. + SetPersonalAccessToken personal_access_token = 4; +} + +message AddPersonalAccessTokenResponse { + zitadel.resources.object.v3alpha.Details details = 1; + // unique identifier of the public key. + string personal_access_token_id = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\""; + } + ]; + string personal_access_token = 3; +} + +message RemovePersonalAccessTokenRequest { + optional zitadel.object.v3alpha.Instance instance = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"domain from HOST or :authority header\"" + } + ]; + // Optionally expect the user to be in this organization. + optional zitadel.object.v3alpha.Organization organization = 2; + // unique identifier of the user. + string id = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; + // unique identifier of the personal access token. + string personal_access_token_id = 4 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629023906488334\""; + } + ]; +} + +message RemovePersonalAccessTokenResponse { + zitadel.resources.object.v3alpha.Details details = 1; +} + message StartWebAuthNRegistrationRequest { optional zitadel.object.v3alpha.Instance instance = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {