From 699fdaf68e988a34a46cda77dd9590ad160d7ce3 Mon Sep 17 00:00:00 2001 From: Livio Amstutz Date: Tue, 8 Feb 2022 09:37:28 +0100 Subject: [PATCH] feat: add personal access tokens for service users (#2974) * feat: add machine tokens * fix test * rename to pat * fix merge and tests * fix scopes * fix migration version * fix test * Update internal/repository/user/personal_access_token.go Co-authored-by: Fabi <38692350+fgerschwiler@users.noreply.github.com> Co-authored-by: Fabi <38692350+fgerschwiler@users.noreply.github.com> --- docs/docs/apis/proto/management.md | 146 ++++++++- docs/docs/apis/proto/user.md | 14 + internal/api/grpc/management/user.go | 72 +++++ .../api/grpc/management/user_converter.go | 24 ++ internal/api/grpc/user/machine_token.go | 25 ++ internal/api/oidc/auth_request.go | 21 ++ internal/api/oidc/client.go | 6 + .../repository/eventsourcing/handler/token.go | 6 +- .../eventstore/token_verifier.go | 4 +- internal/command/user_converter.go | 15 + internal/command/user_model.go | 4 + .../command/user_personal_access_token.go | 92 ++++++ .../user_personal_access_token_model.go | 81 +++++ .../user_personal_access_token_test.go | 297 ++++++++++++++++++ internal/domain/authn_key.go | 23 +- internal/domain/expiration.go | 36 +++ internal/domain/user.go | 14 + internal/query/projection/projection.go | 1 + .../projection/user_personal_access_token.go | 112 +++++++ .../user_personal_access_token_test.go | 128 ++++++++ internal/query/user_personal_access_token.go | 219 +++++++++++++ .../query/user_personal_access_token_test.go | 271 ++++++++++++++++ internal/repository/user/eventstore.go | 4 +- .../repository/user/personal_access_token.go | 105 +++++++ internal/static/i18n/de.yaml | 3 + internal/static/i18n/en.yaml | 3 + internal/static/i18n/it.yaml | 3 + internal/user/model/token_view.go | 1 + internal/user/repository/view/model/token.go | 6 +- .../V1.110__personal_access_token.sql | 14 + proto/zitadel/management.proto | 94 +++++- proto/zitadel/user.proto | 24 +- 32 files changed, 1838 insertions(+), 30 deletions(-) create mode 100644 internal/api/grpc/user/machine_token.go create mode 100644 internal/command/user_personal_access_token.go create mode 100644 internal/command/user_personal_access_token_model.go create mode 100644 internal/command/user_personal_access_token_test.go create mode 100644 internal/domain/expiration.go create mode 100644 internal/query/projection/user_personal_access_token.go create mode 100644 internal/query/projection/user_personal_access_token_test.go create mode 100644 internal/query/user_personal_access_token.go create mode 100644 internal/query/user_personal_access_token_test.go create mode 100644 internal/repository/user/personal_access_token.go create mode 100644 migrations/cockroach/V1.110__personal_access_token.sql diff --git a/docs/docs/apis/proto/management.md b/docs/docs/apis/proto/management.md index 7e44631fcf..502ecf3d7a 100644 --- a/docs/docs/apis/proto/management.md +++ b/docs/docs/apis/proto/management.md @@ -623,13 +623,62 @@ Generates a new machine key, details should be stored after return > **rpc** RemoveMachineKey([RemoveMachineKeyRequest](#removemachinekeyrequest)) [RemoveMachineKeyResponse](#removemachinekeyresponse) -Removed a machine key +Removes a machine key DELETE: /users/{user_id}/keys/{key_id} +### GetPersonalAccessTokenByIDs + +> **rpc** GetPersonalAccessTokenByIDs([GetPersonalAccessTokenByIDsRequest](#getpersonalaccesstokenbyidsrequest)) +[GetPersonalAccessTokenByIDsResponse](#getpersonalaccesstokenbyidsresponse) + +Returns a personal access token of a (machine) user + + + + GET: /users/{user_id}/pats/{token_id} + + +### ListPersonalAccessTokens + +> **rpc** ListPersonalAccessTokens([ListPersonalAccessTokensRequest](#listpersonalaccesstokensrequest)) +[ListPersonalAccessTokensResponse](#listpersonalaccesstokensresponse) + +Returns all personal access tokens of a (machine) user which match the query +Limit should always be set, there is a default limit set by the service + + + + POST: /users/{user_id}/pats/_search + + +### AddPersonalAccessToken + +> **rpc** AddPersonalAccessToken([AddPersonalAccessTokenRequest](#addpersonalaccesstokenrequest)) +[AddPersonalAccessTokenResponse](#addpersonalaccesstokenresponse) + +Generates a new personal access token for a machine user, details should be stored after return + + + + POST: /users/{user_id}/pats + + +### RemovePersonalAccessToken + +> **rpc** RemovePersonalAccessToken([RemovePersonalAccessTokenRequest](#removepersonalaccesstokenrequest)) +[RemovePersonalAccessTokenResponse](#removepersonalaccesstokenresponse) + +Removes a personal access token + + + + DELETE: /users/{user_id}/pats/{token_id} + + ### ListHumanLinkedIDPs > **rpc** ListHumanLinkedIDPs([ListHumanLinkedIDPsRequest](#listhumanlinkedidpsrequest)) @@ -3431,6 +3480,31 @@ This is an empty request +### AddPersonalAccessTokenRequest + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| user_id | string | - | string.min_len: 1
| +| expiration_date | google.protobuf.Timestamp | - | | + + + + +### AddPersonalAccessTokenResponse + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| token_id | string | - | | +| token | string | - | | +| details | zitadel.v1.ObjectDetails | - | | + + + + ### AddProjectGrantMemberRequest @@ -4816,6 +4890,29 @@ This is an empty request +### GetPersonalAccessTokenByIDsRequest + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| user_id | string | - | string.min_len: 1
string.max_len: 200
| +| token_id | string | - | string.min_len: 1
string.max_len: 200
| + + + + +### GetPersonalAccessTokenByIDsResponse + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| token | zitadel.user.v1.PersonalAccessToken | - | | + + + + ### GetPreviewLabelPolicyRequest This is an empty request @@ -5572,6 +5669,30 @@ This is an empty request +### ListPersonalAccessTokensRequest + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| user_id | string | - | string.min_len: 1
string.max_len: 200
| +| query | zitadel.v1.ListQuery | list limitations and ordering | | + + + + +### ListPersonalAccessTokensResponse + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| details | zitadel.v1.ListDetails | - | | +| result | repeated zitadel.user.v1.PersonalAccessToken | - | | + + + + ### ListProjectChangesRequest @@ -6525,6 +6646,29 @@ This is an empty response +### RemovePersonalAccessTokenRequest + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| user_id | string | - | string.min_len: 1
string.max_len: 200
| +| token_id | string | - | string.min_len: 1
string.max_len: 200
| + + + + +### RemovePersonalAccessTokenResponse + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| details | zitadel.v1.ObjectDetails | - | | + + + + ### RemoveProjectGrantMemberRequest diff --git a/docs/docs/apis/proto/user.md b/docs/docs/apis/proto/user.md index 087d315892..2f47a39135 100644 --- a/docs/docs/apis/proto/user.md +++ b/docs/docs/apis/proto/user.md @@ -213,6 +213,20 @@ this query is always equals +### PersonalAccessToken + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| id | string | - | | +| details | zitadel.v1.ObjectDetails | - | | +| expiration_date | google.protobuf.Timestamp | - | | +| scopes | repeated string | - | | + + + + ### Phone diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index 34d56c5ed6..3a829a86f6 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -2,7 +2,9 @@ package management import ( "context" + "time" + "github.com/caos/oidc/pkg/oidc" "google.golang.org/protobuf/types/known/durationpb" "github.com/caos/zitadel/internal/api/authz" @@ -11,7 +13,9 @@ import ( idp_grpc "github.com/caos/zitadel/internal/api/grpc/idp" "github.com/caos/zitadel/internal/api/grpc/metadata" obj_grpc "github.com/caos/zitadel/internal/api/grpc/object" + "github.com/caos/zitadel/internal/api/grpc/user" user_grpc "github.com/caos/zitadel/internal/api/grpc/user" + z_oidc "github.com/caos/zitadel/internal/api/oidc" "github.com/caos/zitadel/internal/domain" "github.com/caos/zitadel/internal/query" mgmt_pb "github.com/caos/zitadel/pkg/grpc/management" @@ -693,6 +697,74 @@ func (s *Server) RemoveMachineKey(ctx context.Context, req *mgmt_pb.RemoveMachin }, nil } +func (s *Server) GetPersonalAccessTokenByIDs(ctx context.Context, req *mgmt_pb.GetPersonalAccessTokenByIDsRequest) (*mgmt_pb.GetPersonalAccessTokenByIDsResponse, error) { + resourceOwner, err := query.NewPersonalAccessTokenResourceOwnerSearchQuery(authz.GetCtxData(ctx).OrgID) + if err != nil { + return nil, err + } + aggregateID, err := query.NewPersonalAccessTokenUserIDSearchQuery(req.UserId) + if err != nil { + return nil, err + } + token, err := s.query.PersonalAccessTokenByID(ctx, req.TokenId, resourceOwner, aggregateID) + if err != nil { + return nil, err + } + return &mgmt_pb.GetPersonalAccessTokenByIDsResponse{ + Token: user.PersonalAccessTokenToPb(token), + }, nil +} + +func (s *Server) ListPersonalAccessTokens(ctx context.Context, req *mgmt_pb.ListPersonalAccessTokensRequest) (*mgmt_pb.ListPersonalAccessTokensResponse, error) { + queries, err := ListPersonalAccessTokensRequestToQuery(ctx, req) + if err != nil { + return nil, err + } + result, err := s.query.SearchPersonalAccessTokens(ctx, queries) + if err != nil { + return nil, err + } + return &mgmt_pb.ListPersonalAccessTokensResponse{ + Result: user_grpc.PersonalAccessTokensToPb(result.PersonalAccessTokens), + Details: obj_grpc.ToListDetails( + result.Count, + result.Sequence, + result.Timestamp, + ), + }, nil +} + +func (s *Server) AddPersonalAccessToken(ctx context.Context, req *mgmt_pb.AddPersonalAccessTokenRequest) (*mgmt_pb.AddPersonalAccessTokenResponse, error) { + expDate := time.Time{} + if req.ExpirationDate != nil { + expDate = req.ExpirationDate.AsTime() + } + scopes := []string{oidc.ScopeOpenID, z_oidc.ScopeUserMetaData, z_oidc.ScopeResourceOwner} + pat, token, err := s.command.AddPersonalAccessToken(ctx, req.UserId, authz.GetCtxData(ctx).OrgID, expDate, scopes, domain.UserTypeMachine) + if err != nil { + return nil, err + } + return &mgmt_pb.AddPersonalAccessTokenResponse{ + TokenId: pat.TokenID, + Token: token, + Details: obj_grpc.AddToDetailsPb( + pat.Sequence, + pat.ChangeDate, + pat.ResourceOwner, + ), + }, nil +} + +func (s *Server) RemovePersonalAccessToken(ctx context.Context, req *mgmt_pb.RemovePersonalAccessTokenRequest) (*mgmt_pb.RemovePersonalAccessTokenResponse, error) { + objectDetails, err := s.command.RemovePersonalAccessToken(ctx, req.UserId, req.TokenId, authz.GetCtxData(ctx).OrgID) + if err != nil { + return nil, err + } + return &mgmt_pb.RemovePersonalAccessTokenResponse{ + Details: obj_grpc.DomainToChangeDetailsPb(objectDetails), + }, nil +} + func (s *Server) ListHumanLinkedIDPs(ctx context.Context, req *mgmt_pb.ListHumanLinkedIDPsRequest) (*mgmt_pb.ListHumanLinkedIDPsResponse, error) { queries, err := ListHumanLinkedIDPsRequestToQuery(ctx, req) if err != nil { diff --git a/internal/api/grpc/management/user_converter.go b/internal/api/grpc/management/user_converter.go index 8aab55a718..b75880a08e 100644 --- a/internal/api/grpc/management/user_converter.go +++ b/internal/api/grpc/management/user_converter.go @@ -227,6 +227,30 @@ func AddMachineKeyRequestToDomain(req *mgmt_pb.AddMachineKeyRequest) *domain.Mac } } +func ListPersonalAccessTokensRequestToQuery(ctx context.Context, req *mgmt_pb.ListPersonalAccessTokensRequest) (*query.PersonalAccessTokenSearchQueries, error) { + resourceOwner, err := query.NewPersonalAccessTokenResourceOwnerSearchQuery(authz.GetCtxData(ctx).OrgID) + if err != nil { + return nil, err + } + userID, err := query.NewPersonalAccessTokenUserIDSearchQuery(req.UserId) + if err != nil { + return nil, err + } + offset, limit, asc := object.ListQueryToModel(req.Query) + return &query.PersonalAccessTokenSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + }, + Queries: []query.SearchQuery{ + resourceOwner, + userID, + }, + }, nil + +} + func RemoveHumanLinkedIDPRequestToDomain(ctx context.Context, req *mgmt_pb.RemoveHumanLinkedIDPRequest) *domain.UserIDPLink { return &domain.UserIDPLink{ ObjectRoot: models.ObjectRoot{ diff --git a/internal/api/grpc/user/machine_token.go b/internal/api/grpc/user/machine_token.go new file mode 100644 index 0000000000..2407ab520c --- /dev/null +++ b/internal/api/grpc/user/machine_token.go @@ -0,0 +1,25 @@ +package user + +import ( + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/caos/zitadel/internal/api/grpc/object" + "github.com/caos/zitadel/internal/query" + "github.com/caos/zitadel/pkg/grpc/user" +) + +func PersonalAccessTokensToPb(tokens []*query.PersonalAccessToken) []*user.PersonalAccessToken { + t := make([]*user.PersonalAccessToken, len(tokens)) + for i, token := range tokens { + t[i] = PersonalAccessTokenToPb(token) + } + return t +} +func PersonalAccessTokenToPb(token *query.PersonalAccessToken) *user.PersonalAccessToken { + return &user.PersonalAccessToken{ + Id: token.ID, + Details: object.ChangeToDetailsPb(token.Sequence, token.ChangeDate, token.ResourceOwner), + ExpirationDate: timestamppb.New(token.Expiration), + Scopes: token.Scopes, + } +} diff --git a/internal/api/oidc/auth_request.go b/internal/api/oidc/auth_request.go index d1e392c40b..ba98b572bb 100644 --- a/internal/api/oidc/auth_request.go +++ b/internal/api/oidc/auth_request.go @@ -14,6 +14,7 @@ import ( "github.com/caos/zitadel/internal/errors" "github.com/caos/zitadel/internal/query" "github.com/caos/zitadel/internal/telemetry/tracing" + "github.com/caos/zitadel/internal/user/model" grant_model "github.com/caos/zitadel/internal/usergrant/model" ) @@ -227,3 +228,23 @@ func (o *OPStorage) assertProjectRoleScopes(ctx context.Context, clientID string } return scopes, nil } + +func (o *OPStorage) assertClientScopesForPAT(ctx context.Context, token *model.TokenView, clientID string) error { + token.Audience = append(token.Audience, clientID) + projectID, err := o.query.ProjectIDFromClientID(ctx, clientID) + if err != nil { + return errors.ThrowPreconditionFailed(nil, "OIDC-AEG4d", "Errors.Internal") + } + projectIDQuery, err := query.NewProjectRoleProjectIDSearchQuery(projectID) + if err != nil { + return errors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal") + } + roles, err := o.query.SearchProjectRoles(context.TODO(), &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}) + if err != nil { + return err + } + for _, role := range roles.ProjectRoles { + token.Scopes = append(token.Scopes, ScopeProjectRolePrefix+role.Key) + } + return nil +} diff --git a/internal/api/oidc/client.go b/internal/api/oidc/client.go index c76a1a4594..bba276ee3e 100644 --- a/internal/api/oidc/client.go +++ b/internal/api/oidc/client.go @@ -163,6 +163,12 @@ func (o *OPStorage) SetIntrospectionFromToken(ctx context.Context, introspection if err != nil { return errors.ThrowPermissionDenied(nil, "OIDC-Adfg5", "client not found") } + if token.IsPAT { + err = o.assertClientScopesForPAT(ctx, token, clientID) + if err != nil { + return errors.ThrowPreconditionFailed(err, "OIDC-AGefw", "Errors.Internal") + } + } for _, aud := range token.Audience { if aud == clientID || aud == projectID { err := o.setUserinfo(ctx, introspection, token.UserID, clientID, token.Scopes) diff --git a/internal/auth/repository/eventsourcing/handler/token.go b/internal/auth/repository/eventsourcing/handler/token.go index 87103a357d..519eb71170 100644 --- a/internal/auth/repository/eventsourcing/handler/token.go +++ b/internal/auth/repository/eventsourcing/handler/token.go @@ -82,7 +82,8 @@ func (t *Token) EventQuery() (*es_models.SearchQuery, error) { func (t *Token) Reduce(event *es_models.Event) (err error) { switch event.Type { - case user_es_model.UserTokenAdded: + case user_es_model.UserTokenAdded, + es_models.EventType(user_repo.PersonalAccessTokenAddedType): token := new(view_model.TokenView) err := token.AppendEvent(event) if err != nil { @@ -112,7 +113,8 @@ func (t *Token) Reduce(event *es_models.Event) (err error) { user_es_model.UserDeactivated, user_es_model.UserRemoved: return t.view.DeleteUserTokens(event.AggregateID, event) - case es_models.EventType(user_repo.UserTokenRemovedType): + case es_models.EventType(user_repo.UserTokenRemovedType), + es_models.EventType(user_repo.PersonalAccessTokenRemovedType): id, err := tokenIDFromRemovedEvent(event) if err != nil { return err diff --git a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go index c2538e0de2..6e7d873db7 100644 --- a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go +++ b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go @@ -86,7 +86,9 @@ func (repo *TokenVerifierRepo) VerifyAccessToken(ctx context.Context, tokenStrin if !token.Expiration.After(time.Now().UTC()) { return "", "", "", "", "", caos_errs.ThrowUnauthenticated(err, "APP-k9KS0", "invalid token") } - + if token.IsPAT { + return token.UserID, "", "", "", token.ResourceOwner, nil + } for _, aud := range token.Audience { if verifierClientID == aud || projectID == aud { return token.UserID, token.UserAgentID, token.ApplicationID, token.PreferredLanguage, token.ResourceOwner, nil diff --git a/internal/command/user_converter.go b/internal/command/user_converter.go index aae254f9f9..7cf38482b6 100644 --- a/internal/command/user_converter.go +++ b/internal/command/user_converter.go @@ -1,6 +1,9 @@ package command import ( + "encoding/base64" + + "github.com/caos/zitadel/internal/crypto" "github.com/caos/zitadel/internal/domain" "github.com/caos/zitadel/internal/repository/user" ) @@ -97,6 +100,18 @@ func keyWriteModelToMachineKey(wm *MachineKeyWriteModel) *domain.MachineKey { } } +func personalTokenWriteModelToToken(wm *PersonalAccessTokenWriteModel, algorithm crypto.EncryptionAlgorithm) (*domain.Token, string, error) { + encrypted, err := algorithm.Encrypt([]byte(wm.TokenID + ":" + wm.AggregateID)) + if err != nil { + return nil, "", err + } + return &domain.Token{ + ObjectRoot: writeModelToObjectRoot(wm.WriteModel), + TokenID: wm.TokenID, + Expiration: wm.ExpirationDate, + }, base64.RawURLEncoding.EncodeToString(encrypted), nil +} + func readModelToU2FTokens(wm *HumanU2FTokensReadModel) []*domain.WebAuthNToken { tokens := make([]*domain.WebAuthNToken, len(wm.WebAuthNTokens)) for i, token := range wm.WebAuthNTokens { diff --git a/internal/command/user_model.go b/internal/command/user_model.go index 003128eadf..ef7680600a 100644 --- a/internal/command/user_model.go +++ b/internal/command/user_model.go @@ -16,6 +16,7 @@ type UserWriteModel struct { UserName string IDPLinks []*domain.UserIDPLink UserState domain.UserState + UserType domain.UserType } func NewUserWriteModel(userID, resourceOwner string) *UserWriteModel { @@ -34,9 +35,11 @@ func (wm *UserWriteModel) Reduce() error { case *user.HumanAddedEvent: wm.UserName = e.UserName wm.UserState = domain.UserStateActive + wm.UserType = domain.UserTypeHuman case *user.HumanRegisteredEvent: wm.UserName = e.UserName wm.UserState = domain.UserStateActive + wm.UserType = domain.UserTypeHuman case *user.HumanInitialCodeAddedEvent: wm.UserState = domain.UserStateInitial case *user.HumanInitializedCheckSucceededEvent: @@ -62,6 +65,7 @@ func (wm *UserWriteModel) Reduce() error { case *user.MachineAddedEvent: wm.UserName = e.UserName wm.UserState = domain.UserStateActive + wm.UserType = domain.UserTypeMachine case *user.UsernameChangedEvent: wm.UserName = e.UserName case *user.UserLockedEvent: diff --git a/internal/command/user_personal_access_token.go b/internal/command/user_personal_access_token.go new file mode 100644 index 0000000000..5cf9845584 --- /dev/null +++ b/internal/command/user_personal_access_token.go @@ -0,0 +1,92 @@ +package command + +import ( + "context" + "time" + + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/repository/user" + "github.com/caos/zitadel/internal/telemetry/tracing" +) + +func (c *Commands) AddPersonalAccessToken(ctx context.Context, userID, resourceOwner string, expirationDate time.Time, scopes []string, allowedUserType domain.UserType) (*domain.Token, string, error) { + userWriteModel, err := c.userWriteModelByID(ctx, userID, resourceOwner) + if err != nil { + return nil, "", err + } + if !isUserStateExists(userWriteModel.UserState) { + return nil, "", errors.ThrowPreconditionFailed(nil, "COMMAND-Dggw2", "Errors.User.NotFound") + } + if allowedUserType != domain.UserTypeUnspecified && userWriteModel.UserType != allowedUserType { + return nil, "", errors.ThrowPreconditionFailed(nil, "COMMAND-Df2f1", "Errors.User.WrongType") + } + tokenID, err := c.idGenerator.Next() + if err != nil { + return nil, "", err + } + tokenWriteModel := NewPersonalAccessTokenWriteModel(userID, tokenID, resourceOwner) + err = c.eventstore.FilterToQueryReducer(ctx, tokenWriteModel) + if err != nil { + return nil, "", err + } + + expirationDate, err = domain.ValidateExpirationDate(expirationDate) + if err != nil { + return nil, "", err + } + + events, err := c.eventstore.Push(ctx, + user.NewPersonalAccessTokenAddedEvent( + ctx, + UserAggregateFromWriteModel(&tokenWriteModel.WriteModel), + tokenID, + expirationDate, + scopes, + ), + ) + if err != nil { + return nil, "", err + } + err = AppendAndReduce(tokenWriteModel, events...) + if err != nil { + return nil, "", err + } + return personalTokenWriteModelToToken(tokenWriteModel, c.keyAlgorithm) +} + +func (c *Commands) RemovePersonalAccessToken(ctx context.Context, userID, tokenID, resourceOwner string) (*domain.ObjectDetails, error) { + tokenWriteModel, err := c.personalAccessTokenWriteModelByID(ctx, userID, tokenID, resourceOwner) + if err != nil { + return nil, err + } + if !tokenWriteModel.Exists() { + return nil, errors.ThrowNotFound(nil, "COMMAND-4m77G", "Errors.User.PAT.NotFound") + } + + pushedEvents, err := c.eventstore.Push(ctx, + user.NewPersonalAccessTokenRemovedEvent(ctx, UserAggregateFromWriteModel(&tokenWriteModel.WriteModel), tokenID)) + if err != nil { + return nil, err + } + err = AppendAndReduce(tokenWriteModel, pushedEvents...) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&tokenWriteModel.WriteModel), nil +} + +func (c *Commands) personalAccessTokenWriteModelByID(ctx context.Context, userID, tokenID, resourceOwner string) (writeModel *PersonalAccessTokenWriteModel, err error) { + if userID == "" { + return nil, errors.ThrowInvalidArgument(nil, "COMMAND-4n8vs", "Errors.User.UserIDMissing") + } + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + writeModel = NewPersonalAccessTokenWriteModel(userID, tokenID, resourceOwner) + err = c.eventstore.FilterToQueryReducer(ctx, writeModel) + if err != nil { + return nil, err + } + return writeModel, nil +} diff --git a/internal/command/user_personal_access_token_model.go b/internal/command/user_personal_access_token_model.go new file mode 100644 index 0000000000..63ce19a03a --- /dev/null +++ b/internal/command/user_personal_access_token_model.go @@ -0,0 +1,81 @@ +package command + +import ( + "time" + + "github.com/caos/zitadel/internal/eventstore" + + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/repository/user" +) + +type PersonalAccessTokenWriteModel struct { + eventstore.WriteModel + + TokenID string + ExpirationDate time.Time + + State domain.PersonalAccessTokenState +} + +func NewPersonalAccessTokenWriteModel(userID, tokenID, resourceOwner string) *PersonalAccessTokenWriteModel { + return &PersonalAccessTokenWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: userID, + ResourceOwner: resourceOwner, + }, + TokenID: tokenID, + } +} + +func (wm *PersonalAccessTokenWriteModel) AppendEvents(events ...eventstore.Event) { + for _, event := range events { + switch e := event.(type) { + case *user.PersonalAccessTokenAddedEvent: + if wm.TokenID != e.TokenID { + continue + } + wm.WriteModel.AppendEvents(e) + case *user.PersonalAccessTokenRemovedEvent: + if wm.TokenID != e.TokenID { + continue + } + wm.WriteModel.AppendEvents(e) + case *user.UserRemovedEvent: + wm.WriteModel.AppendEvents(e) + } + } +} + +func (wm *PersonalAccessTokenWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *user.PersonalAccessTokenAddedEvent: + wm.TokenID = e.TokenID + wm.ExpirationDate = e.Expiration + wm.State = domain.PersonalAccessTokenStateActive + case *user.PersonalAccessTokenRemovedEvent: + wm.State = domain.PersonalAccessTokenStateRemoved + case *user.UserRemovedEvent: + wm.State = domain.PersonalAccessTokenStateRemoved + } + } + return wm.WriteModel.Reduce() +} + +func (wm *PersonalAccessTokenWriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + ResourceOwner(wm.ResourceOwner). + AddQuery(). + AggregateTypes(user.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes( + user.PersonalAccessTokenAddedType, + user.PersonalAccessTokenRemovedType, + user.UserRemovedType). + Builder() +} + +func (wm *PersonalAccessTokenWriteModel) Exists() bool { + return wm.State != domain.PersonalAccessTokenStateUnspecified && wm.State != domain.PersonalAccessTokenStateRemoved +} diff --git a/internal/command/user_personal_access_token_test.go b/internal/command/user_personal_access_token_test.go new file mode 100644 index 0000000000..f2437a074b --- /dev/null +++ b/internal/command/user_personal_access_token_test.go @@ -0,0 +1,297 @@ +package command + +import ( + "context" + "encoding/base64" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + + "github.com/caos/zitadel/internal/eventstore/repository" + + "github.com/caos/zitadel/internal/eventstore/v1/models" + + id_mock "github.com/caos/zitadel/internal/id/mock" + + "github.com/caos/zitadel/internal/repository/user" + + caos_errs "github.com/caos/zitadel/internal/errors" + + "github.com/caos/zitadel/internal/crypto" + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/id" +) + +func TestCommands_AddPersonalAccessToken(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + idGenerator id.Generator + keyAlgorithm crypto.EncryptionAlgorithm + } + type args struct { + ctx context.Context + userID string + resourceOwner string + expirationDate time.Time + scopes []string + allowedUserType domain.UserType + } + type res struct { + want *domain.Token + token string + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "user does not exist, error", + fields{ + eventstore: eventstoreExpect(t, + expectFilter(), + ), + }, + args{ + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + scopes: []string{"openid"}, + expirationDate: time.Time{}, + }, + res{ + err: caos_errs.IsPreconditionFailed, + }, + }, + { + "user type not allowed, error", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "machine", + "Machine", + "", + true, + ), + ), + ), + ), + }, + args{ + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + expirationDate: time.Time{}, + scopes: []string{"openid"}, + allowedUserType: domain.UserTypeHuman, + }, + res{ + err: caos_errs.IsPreconditionFailed, + }, + }, + { + "invalid expiration date, error", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "machine", + "Machine", + "", + true, + ), + ), + ), + expectFilter(), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "token1"), + }, + args{ + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + expirationDate: time.Now().Add(-24 * time.Hour), + scopes: []string{"openid"}, + }, + res{ + err: caos_errs.IsErrorInvalidArgument, + }, + }, + { + "token added", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "machine", + "Machine", + "", + true, + ), + ), + ), + expectFilter(), + expectPush( + []*repository.Event{ + eventFromEventPusher( + user.NewPersonalAccessTokenAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "token1", + time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), + []string{"openid"}, + ), + ), + }, + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "token1"), + keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args{ + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + expirationDate: time.Time{}, + scopes: []string{"openid"}, + }, + res{ + want: &domain.Token{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + TokenID: "token1", + Expiration: time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), + }, + token: base64.RawURLEncoding.EncodeToString([]byte("token1:user1")), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + idGenerator: tt.fields.idGenerator, + keyAlgorithm: tt.fields.keyAlgorithm, + } + got, token, err := c.AddPersonalAccessToken(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.expirationDate, tt.args.scopes, tt.args.allowedUserType) + 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 { + assert.Equal(t, tt.res.want, got) + assert.Equal(t, tt.res.token, token) + } + }) + } +} + +func TestCommands_RemovePersonalAccessToken(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + } + type args struct { + ctx context.Context + userID string + tokenID string + resourceOwner string + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "token does not exist, error", + fields{ + eventstore: eventstoreExpect(t, + expectFilter(), + ), + }, + args{ + ctx: context.Background(), + userID: "user1", + tokenID: "token1", + resourceOwner: "org1", + }, + res{ + err: caos_errs.IsNotFound, + }, + }, + { + "remove token, ok", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + user.NewPersonalAccessTokenAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "token1", + time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), + []string{"openid"}, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + user.NewPersonalAccessTokenRemovedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "token1", + ), + ), + }, + ), + ), + }, + args{ + ctx: context.Background(), + userID: "user1", + tokenID: "token1", + resourceOwner: "org1", + }, + res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + } + got, err := c.RemovePersonalAccessToken(tt.args.ctx, tt.args.userID, tt.args.tokenID, tt.args.resourceOwner) + 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 { + assert.Equal(t, tt.res.want, got) + } + }) + } +} diff --git a/internal/domain/authn_key.go b/internal/domain/authn_key.go index df3d37fcbe..c62542229d 100644 --- a/internal/domain/authn_key.go +++ b/internal/domain/authn_key.go @@ -1,27 +1,16 @@ package domain import ( - "time" - "github.com/caos/logging" "github.com/caos/zitadel/internal/crypto" "github.com/caos/zitadel/internal/errors" ) -var ( - //most of us won't survive until 12-31-9999 23:59:59, maybe ZITADEL does - defaultExpDate = time.Date(9999, time.December, 31, 23, 59, 59, 0, time.UTC) -) - -type AuthNKey interface { -} - type authNKey interface { setPublicKey([]byte) setPrivateKey([]byte) - expirationDate() time.Time - setExpirationDate(time.Time) + expiration } type AuthNKeyType int32 @@ -50,16 +39,6 @@ func (key *MachineKey) GenerateNewMachineKeyPair(keySize int) error { return nil } -func EnsureValidExpirationDate(key authNKey) error { - if key.expirationDate().IsZero() { - key.setExpirationDate(defaultExpDate) - } - if key.expirationDate().Before(time.Now()) { - return errors.ThrowInvalidArgument(nil, "AUTHN-dv3t5", "Errors.AuthNKey.ExpireBeforeNow") - } - return nil -} - func SetNewAuthNKeyPair(key authNKey, keySize int) error { privateKey, publicKey, err := NewAuthNKeyPair(keySize) if err != nil { diff --git a/internal/domain/expiration.go b/internal/domain/expiration.go new file mode 100644 index 0000000000..46e3fb7e06 --- /dev/null +++ b/internal/domain/expiration.go @@ -0,0 +1,36 @@ +package domain + +import ( + "time" + + "github.com/caos/zitadel/internal/errors" +) + +var ( + //most of us won't survive until 12-31-9999 23:59:59, maybe ZITADEL does + defaultExpDate = time.Date(9999, time.December, 31, 23, 59, 59, 0, time.UTC) +) + +type expiration interface { + expirationDate() time.Time + setExpirationDate(time.Time) +} + +func EnsureValidExpirationDate(key expiration) error { + date, err := ValidateExpirationDate(key.expirationDate()) + if err != nil { + return err + } + key.setExpirationDate(date) + return nil +} + +func ValidateExpirationDate(date time.Time) (time.Time, error) { + if date.IsZero() { + return defaultExpDate, nil + } + if date.Before(time.Now()) { + return time.Time{}, errors.ThrowInvalidArgument(nil, "DOMAIN-dv3t5", "Errors.AuthNKey.ExpireBeforeNow") + } + return date, nil +} diff --git a/internal/domain/user.go b/internal/domain/user.go index de79f58328..71b5c41261 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -53,3 +53,17 @@ const ( func (f UserAuthMethodType) Valid() bool { return f >= 0 && f < userAuthMethodTypeCount } + +type PersonalAccessTokenState int32 + +const ( + PersonalAccessTokenStateUnspecified PersonalAccessTokenState = iota + PersonalAccessTokenStateActive + PersonalAccessTokenStateRemoved + + personalAccessTokenStateCount +) + +func (f PersonalAccessTokenState) Valid() bool { + return f >= 0 && f < personalAccessTokenStateCount +} diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index f362735c18..60e6e0c556 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -64,6 +64,7 @@ func Start(ctx context.Context, sqlClient *sql.DB, es *eventstore.Eventstore, co NewProjectMemberProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["project_members"])) NewProjectGrantMemberProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["project_grant_members"])) NewAuthNKeyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["authn_keys"])) + NewPersonalAccessTokenProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["personal_access_tokens"])) NewUserGrantProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["user_grants"])) NewUserMetadataProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["user_metadata"])) NewUserAuthMethodProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["user_auth_method"])) diff --git a/internal/query/projection/user_personal_access_token.go b/internal/query/projection/user_personal_access_token.go new file mode 100644 index 0000000000..74ce54f1ef --- /dev/null +++ b/internal/query/projection/user_personal_access_token.go @@ -0,0 +1,112 @@ +package projection + +import ( + "context" + + "github.com/caos/logging" + "github.com/lib/pq" + + "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/eventstore/handler" + "github.com/caos/zitadel/internal/eventstore/handler/crdb" + "github.com/caos/zitadel/internal/repository/user" +) + +type PersonalAccessTokenProjection struct { + crdb.StatementHandler +} + +const ( + PersonalAccessTokenProjectionTable = "zitadel.projections.personal_access_tokens" +) + +func NewPersonalAccessTokenProjection(ctx context.Context, config crdb.StatementHandlerConfig) *PersonalAccessTokenProjection { + p := &PersonalAccessTokenProjection{} + config.ProjectionName = PersonalAccessTokenProjectionTable + config.Reducers = p.reducers() + p.StatementHandler = crdb.NewStatementHandler(ctx, config) + return p +} + +func (p *PersonalAccessTokenProjection) reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: user.AggregateType, + EventRedusers: []handler.EventReducer{ + { + Event: user.PersonalAccessTokenAddedType, + Reduce: p.reducePersonalAccessTokenAdded, + }, + { + Event: user.PersonalAccessTokenRemovedType, + Reduce: p.reducePersonalAccessTokenRemoved, + }, + { + Event: user.UserRemovedType, + Reduce: p.reduceUserRemoved, + }, + }, + }, + } +} + +const ( + PersonalAccessTokenColumnID = "id" + PersonalAccessTokenColumnCreationDate = "creation_date" + PersonalAccessTokenColumnChangeDate = "change_date" + PersonalAccessTokenColumnResourceOwner = "resource_owner" + PersonalAccessTokenColumnSequence = "sequence" + PersonalAccessTokenColumnUserID = "user_id" + PersonalAccessTokenColumnExpiration = "expiration" + PersonalAccessTokenColumnScopes = "scopes" +) + +func (p *PersonalAccessTokenProjection) reducePersonalAccessTokenAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*user.PersonalAccessTokenAddedEvent) + if !ok { + logging.LogWithFields("HANDL-Dbfg2", "seq", event.Sequence(), "expectedType", user.PersonalAccessTokenAddedType).Error("wrong event type") + return nil, errors.ThrowInvalidArgument(nil, "HANDL-DVgf7", "reduce.wrong.event.type") + } + return crdb.NewCreateStatement( + e, + []handler.Column{ + handler.NewCol(PersonalAccessTokenColumnID, e.TokenID), + handler.NewCol(PersonalAccessTokenColumnCreationDate, e.CreationDate()), + handler.NewCol(PersonalAccessTokenColumnChangeDate, e.CreationDate()), + handler.NewCol(PersonalAccessTokenColumnResourceOwner, e.Aggregate().ResourceOwner), + handler.NewCol(PersonalAccessTokenColumnSequence, e.Sequence()), + handler.NewCol(PersonalAccessTokenColumnUserID, e.Aggregate().ID), + handler.NewCol(PersonalAccessTokenColumnExpiration, e.Expiration), + handler.NewCol(PersonalAccessTokenColumnScopes, pq.StringArray(e.Scopes)), + }, + ), nil +} + +func (p *PersonalAccessTokenProjection) reducePersonalAccessTokenRemoved(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*user.PersonalAccessTokenRemovedEvent) + if !ok { + logging.LogWithFields("HANDL-Edf32", "seq", event.Sequence(), "expectedType", user.PersonalAccessTokenRemovedType).Error("wrong event type") + return nil, errors.ThrowInvalidArgument(nil, "HANDL-g7u3F", "reduce.wrong.event.type") + } + return crdb.NewDeleteStatement( + e, + []handler.Condition{ + handler.NewCond(PersonalAccessTokenColumnID, e.TokenID), + }, + ), nil +} + +func (p *PersonalAccessTokenProjection) reduceUserRemoved(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*user.UserRemovedEvent) + if !ok { + logging.LogWithFields("HANDL-GEg43", "seq", event.Sequence(), "expectedType", user.UserRemovedType).Error("wrong event type") + return nil, errors.ThrowInvalidArgument(nil, "HANDL-Dff3h", "reduce.wrong.event.type") + } + return crdb.NewDeleteStatement( + e, + []handler.Condition{ + handler.NewCond(PersonalAccessTokenColumnUserID, e.Aggregate().ID), + }, + ), nil +} diff --git a/internal/query/projection/user_personal_access_token_test.go b/internal/query/projection/user_personal_access_token_test.go new file mode 100644 index 0000000000..a3041d8e10 --- /dev/null +++ b/internal/query/projection/user_personal_access_token_test.go @@ -0,0 +1,128 @@ +package projection + +import ( + "testing" + "time" + + "github.com/lib/pq" + + "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/eventstore/handler" + "github.com/caos/zitadel/internal/eventstore/repository" + "github.com/caos/zitadel/internal/repository/user" +) + +func TestPersonalAccessTokenProjection_reduces(t *testing.T) { + type args struct { + event func(t *testing.T) eventstore.Event + } + tests := []struct { + name string + args args + reduce func(event eventstore.Event) (*handler.Statement, error) + want wantReduce + }{ + { + name: "reducePersonalAccessTokenAdded", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.PersonalAccessTokenAddedType), + user.AggregateType, + []byte(`{"tokenId": "tokenID", "expiration": "9999-12-31T23:59:59Z", "scopes": ["openid"]}`), + ), user.PersonalAccessTokenAddedEventMapper), + }, + reduce: (&PersonalAccessTokenProjection{}).reducePersonalAccessTokenAdded, + want: wantReduce{ + projection: PersonalAccessTokenProjectionTable, + aggregateType: eventstore.AggregateType("user"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO zitadel.projections.personal_access_tokens (id, creation_date, change_date, resource_owner, sequence, user_id, expiration, scopes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + expectedArgs: []interface{}{ + "tokenID", + anyArg{}, + anyArg{}, + "ro-id", + uint64(15), + "agg-id", + time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), + pq.StringArray{"openid"}, + }, + }, + }, + }, + }, + }, + { + name: "reducePersonalAccessTokenRemoved", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.PersonalAccessTokenRemovedType), + user.AggregateType, + []byte(`{"tokenId": "tokenID"}`), + ), user.PersonalAccessTokenRemovedEventMapper), + }, + reduce: (&PersonalAccessTokenProjection{}).reducePersonalAccessTokenRemoved, + want: wantReduce{ + projection: PersonalAccessTokenProjectionTable, + aggregateType: eventstore.AggregateType("user"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM zitadel.projections.personal_access_tokens WHERE (id = $1)", + expectedArgs: []interface{}{ + "tokenID", + }, + }, + }, + }, + }, + }, + { + name: "reduceUserRemoved", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.PersonalAccessTokenRemovedType), + user.AggregateType, + nil, + ), user.UserRemovedEventMapper), + }, + reduce: (&PersonalAccessTokenProjection{}).reduceUserRemoved, + want: wantReduce{ + projection: PersonalAccessTokenProjectionTable, + aggregateType: eventstore.AggregateType("user"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM zitadel.projections.personal_access_tokens WHERE (user_id = $1)", + expectedArgs: []interface{}{ + "agg-id", + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := baseEvent(t) + got, err := tt.reduce(event) + if _, ok := err.(errors.InvalidArgument); !ok { + t.Errorf("no wrong event mapping: %v, got: %v", err, got) + } + + event = tt.args.event(t) + got, err = tt.reduce(event) + assertReduce(t, got, err, tt.want) + }) + } +} diff --git a/internal/query/user_personal_access_token.go b/internal/query/user_personal_access_token.go new file mode 100644 index 0000000000..b0cf26178c --- /dev/null +++ b/internal/query/user_personal_access_token.go @@ -0,0 +1,219 @@ +package query + +import ( + "context" + "database/sql" + errs "errors" + "time" + + sq "github.com/Masterminds/squirrel" + "github.com/lib/pq" + + "github.com/caos/zitadel/internal/query/projection" + + "github.com/caos/zitadel/internal/errors" +) + +var ( + personalAccessTokensTable = table{ + name: projection.PersonalAccessTokenProjectionTable, + } + PersonalAccessTokenColumnID = Column{ + name: projection.PersonalAccessTokenColumnID, + table: personalAccessTokensTable, + } + PersonalAccessTokenColumnUserID = Column{ + name: projection.PersonalAccessTokenColumnUserID, + table: personalAccessTokensTable, + } + PersonalAccessTokenColumnExpiration = Column{ + name: projection.PersonalAccessTokenColumnExpiration, + table: personalAccessTokensTable, + } + PersonalAccessTokenColumnScopes = Column{ + name: projection.PersonalAccessTokenColumnScopes, + table: personalAccessTokensTable, + } + PersonalAccessTokenColumnCreationDate = Column{ + name: projection.PersonalAccessTokenColumnCreationDate, + table: personalAccessTokensTable, + } + PersonalAccessTokenColumnChangeDate = Column{ + name: projection.PersonalAccessTokenColumnChangeDate, + table: personalAccessTokensTable, + } + PersonalAccessTokenColumnResourceOwner = Column{ + name: projection.PersonalAccessTokenColumnResourceOwner, + table: personalAccessTokensTable, + } + PersonalAccessTokenColumnSequence = Column{ + name: projection.PersonalAccessTokenColumnSequence, + table: personalAccessTokensTable, + } +) + +type PersonalAccessTokens struct { + SearchResponse + PersonalAccessTokens []*PersonalAccessToken +} + +type PersonalAccessToken struct { + ID string + CreationDate time.Time + ChangeDate time.Time + ResourceOwner string + Sequence uint64 + + UserID string + Expiration time.Time + Scopes []string +} + +type PersonalAccessTokenSearchQueries struct { + SearchRequest + Queries []SearchQuery +} + +func (q *Queries) PersonalAccessTokenByID(ctx context.Context, id string, queries ...SearchQuery) (*PersonalAccessToken, error) { + query, scan := preparePersonalAccessTokenQuery() + for _, q := range queries { + query = q.toQuery(query) + } + stmt, args, err := query.Where(sq.Eq{ + PersonalAccessTokenColumnID.identifier(): id, + }).ToSql() + if err != nil { + return nil, errors.ThrowInternal(err, "QUERY-Dgfb4", "Errors.Query.SQLStatment") + } + + row := q.client.QueryRowContext(ctx, stmt, args...) + return scan(row) +} + +func (q *Queries) SearchPersonalAccessTokens(ctx context.Context, queries *PersonalAccessTokenSearchQueries) (personalAccessTokens *PersonalAccessTokens, err error) { + query, scan := preparePersonalAccessTokensQuery() + stmt, args, err := queries.toQuery(query).ToSql() + if err != nil { + return nil, errors.ThrowInvalidArgument(err, "QUERY-Hjw2w", "Errors.Query.InvalidRequest") + } + + rows, err := q.client.QueryContext(ctx, stmt, args...) + if err != nil { + return nil, errors.ThrowInternal(err, "QUERY-Bmz63", "Errors.Internal") + } + personalAccessTokens, err = scan(rows) + if err != nil { + return nil, err + } + personalAccessTokens.LatestSequence, err = q.latestSequence(ctx, personalAccessTokensTable) + return personalAccessTokens, err +} + +func NewPersonalAccessTokenResourceOwnerSearchQuery(value string) (SearchQuery, error) { + return NewTextQuery(PersonalAccessTokenColumnResourceOwner, value, TextEquals) +} + +func NewPersonalAccessTokenUserIDSearchQuery(value string) (SearchQuery, error) { + return NewTextQuery(PersonalAccessTokenColumnUserID, value, TextEquals) +} + +func (r *PersonalAccessTokenSearchQueries) AppendMyResourceOwnerQuery(orgID string) error { + query, err := NewPersonalAccessTokenResourceOwnerSearchQuery(orgID) + if err != nil { + return err + } + r.Queries = append(r.Queries, query) + return nil +} + +func (q *PersonalAccessTokenSearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder { + query = q.SearchRequest.toQuery(query) + for _, q := range q.Queries { + query = q.toQuery(query) + } + return query +} + +func preparePersonalAccessTokenQuery() (sq.SelectBuilder, func(*sql.Row) (*PersonalAccessToken, error)) { + return sq.Select( + PersonalAccessTokenColumnID.identifier(), + PersonalAccessTokenColumnCreationDate.identifier(), + PersonalAccessTokenColumnChangeDate.identifier(), + PersonalAccessTokenColumnResourceOwner.identifier(), + PersonalAccessTokenColumnSequence.identifier(), + PersonalAccessTokenColumnUserID.identifier(), + PersonalAccessTokenColumnExpiration.identifier(), + PersonalAccessTokenColumnScopes.identifier()). + From(personalAccessTokensTable.identifier()).PlaceholderFormat(sq.Dollar), + func(row *sql.Row) (*PersonalAccessToken, error) { + p := new(PersonalAccessToken) + scopes := pq.StringArray{} + err := row.Scan( + &p.ID, + &p.CreationDate, + &p.ChangeDate, + &p.ResourceOwner, + &p.Sequence, + &p.UserID, + &p.Expiration, + &scopes, + ) + p.Scopes = scopes + if err != nil { + if errs.Is(err, sql.ErrNoRows) { + return nil, errors.ThrowNotFound(err, "QUERY-fk2fs", "Errors.PersonalAccessToken.NotFound") + } + return nil, errors.ThrowInternal(err, "QUERY-dj2FF", "Errors.Internal") + } + return p, nil + } +} + +func preparePersonalAccessTokensQuery() (sq.SelectBuilder, func(*sql.Rows) (*PersonalAccessTokens, error)) { + return sq.Select( + PersonalAccessTokenColumnID.identifier(), + PersonalAccessTokenColumnCreationDate.identifier(), + PersonalAccessTokenColumnChangeDate.identifier(), + PersonalAccessTokenColumnResourceOwner.identifier(), + PersonalAccessTokenColumnSequence.identifier(), + PersonalAccessTokenColumnUserID.identifier(), + PersonalAccessTokenColumnExpiration.identifier(), + PersonalAccessTokenColumnScopes.identifier(), + countColumn.identifier()). + From(personalAccessTokensTable.identifier()).PlaceholderFormat(sq.Dollar), + func(rows *sql.Rows) (*PersonalAccessTokens, error) { + personalAccessTokens := make([]*PersonalAccessToken, 0) + var count uint64 + for rows.Next() { + token := new(PersonalAccessToken) + scopes := pq.StringArray{} + err := rows.Scan( + &token.ID, + &token.CreationDate, + &token.ChangeDate, + &token.ResourceOwner, + &token.Sequence, + &token.UserID, + &token.Expiration, + &scopes, + &count, + ) + if err != nil { + return nil, err + } + token.Scopes = scopes + personalAccessTokens = append(personalAccessTokens, token) + } + + if err := rows.Close(); err != nil { + return nil, errors.ThrowInternal(err, "QUERY-QMXJv", "Errors.Query.CloseRows") + } + + return &PersonalAccessTokens{ + PersonalAccessTokens: personalAccessTokens, + SearchResponse: SearchResponse{ + Count: count, + }, + }, nil + } +} diff --git a/internal/query/user_personal_access_token_test.go b/internal/query/user_personal_access_token_test.go new file mode 100644 index 0000000000..8d35b67967 --- /dev/null +++ b/internal/query/user_personal_access_token_test.go @@ -0,0 +1,271 @@ +package query + +import ( + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "regexp" + "testing" + "time" + + "github.com/lib/pq" + + errs "github.com/caos/zitadel/internal/errors" +) + +var ( + personalAccessTokenStmt = regexp.QuoteMeta( + "SELECT zitadel.projections.personal_access_tokens.id," + + " zitadel.projections.personal_access_tokens.creation_date," + + " zitadel.projections.personal_access_tokens.change_date," + + " zitadel.projections.personal_access_tokens.resource_owner," + + " zitadel.projections.personal_access_tokens.sequence," + + " zitadel.projections.personal_access_tokens.user_id," + + " zitadel.projections.personal_access_tokens.expiration," + + " zitadel.projections.personal_access_tokens.scopes" + + " FROM zitadel.projections.personal_access_tokens") + personalAccessTokenCols = []string{ + "id", + "creation_date", + "change_date", + "resource_owner", + "sequence", + "user_id", + "expiration", + "scopes", + } + personalAccessTokensStmt = regexp.QuoteMeta( + "SELECT zitadel.projections.personal_access_tokens.id," + + " zitadel.projections.personal_access_tokens.creation_date," + + " zitadel.projections.personal_access_tokens.change_date," + + " zitadel.projections.personal_access_tokens.resource_owner," + + " zitadel.projections.personal_access_tokens.sequence," + + " zitadel.projections.personal_access_tokens.user_id," + + " zitadel.projections.personal_access_tokens.expiration," + + " zitadel.projections.personal_access_tokens.scopes," + + " COUNT(*) OVER ()" + + " FROM zitadel.projections.personal_access_tokens") + personalAccessTokensCols = []string{ + "id", + "creation_date", + "change_date", + "resource_owner", + "sequence", + "user_id", + "expiration", + "scopes", + "count", + } +) + +func Test_PersonalAccessTokenPrepares(t *testing.T) { + type want struct { + sqlExpectations sqlExpectation + err checkErr + } + tests := []struct { + name string + prepare interface{} + want want + object interface{} + }{ + { + name: "preparePersonalAccessTokenQuery no result", + prepare: preparePersonalAccessTokenQuery, + want: want{ + sqlExpectations: mockQuery( + personalAccessTokenStmt, + nil, + nil, + ), + err: func(err error) (error, bool) { + if !errs.IsNotFound(err) { + return fmt.Errorf("err should be zitadel.NotFoundError got: %w", err), false + } + return nil, true + }, + }, + object: (*PersonalAccessToken)(nil), + }, + { + name: "preparePersonalAccessTokenQuery found", + prepare: preparePersonalAccessTokenQuery, + want: want{ + sqlExpectations: mockQuery( + personalAccessTokenStmt, + personalAccessTokenCols, + []driver.Value{ + "token-id", + testNow, + testNow, + "ro", + uint64(20211202), + "user-id", + time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), + pq.StringArray{"openid"}, + }, + ), + }, + object: &PersonalAccessToken{ + ID: "token-id", + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "ro", + Sequence: 20211202, + UserID: "user-id", + Expiration: time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), + Scopes: []string{"openid"}, + }, + }, + { + name: "preparePersonalAccessTokenQuery sql err", + prepare: preparePersonalAccessTokenQuery, + want: want{ + sqlExpectations: mockQueryErr( + personalAccessTokenStmt, + sql.ErrConnDone, + ), + err: func(err error) (error, bool) { + if !errors.Is(err, sql.ErrConnDone) { + return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false + } + return nil, true + }, + }, + object: nil, + }, + { + name: "preparePersonalAccessTokensQuery no result", + prepare: preparePersonalAccessTokensQuery, + want: want{ + sqlExpectations: mockQueries( + personalAccessTokensStmt, + nil, + nil, + ), + }, + object: &PersonalAccessTokens{PersonalAccessTokens: []*PersonalAccessToken{}}, + }, + { + name: "preparePersonalAccessTokensQuery one token", + prepare: preparePersonalAccessTokensQuery, + want: want{ + sqlExpectations: mockQueries( + personalAccessTokensStmt, + personalAccessTokensCols, + [][]driver.Value{ + { + "token-id", + testNow, + testNow, + "ro", + uint64(20211202), + "user-id", + time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), + pq.StringArray{"openid"}, + }, + }, + ), + }, + object: &PersonalAccessTokens{ + SearchResponse: SearchResponse{ + Count: 1, + }, + PersonalAccessTokens: []*PersonalAccessToken{ + { + ID: "token-id", + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "ro", + Sequence: 20211202, + UserID: "user-id", + Expiration: time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), + Scopes: []string{"openid"}, + }, + }, + }, + }, + { + name: "preparePersonalAccessTokensQuery multiple tokens", + prepare: preparePersonalAccessTokensQuery, + want: want{ + sqlExpectations: mockQueries( + personalAccessTokensStmt, + personalAccessTokensCols, + [][]driver.Value{ + { + "token-id", + testNow, + testNow, + "ro", + uint64(20211202), + "user-id", + time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), + pq.StringArray{"openid"}, + }, + { + "token-id2", + testNow, + testNow, + "ro", + uint64(20211202), + "user-id", + time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), + pq.StringArray{"openid"}, + }, + }, + ), + }, + object: &PersonalAccessTokens{ + SearchResponse: SearchResponse{ + Count: 2, + }, + PersonalAccessTokens: []*PersonalAccessToken{ + { + ID: "token-id", + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "ro", + Sequence: 20211202, + UserID: "user-id", + Expiration: time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), + Scopes: []string{"openid"}, + }, + { + ID: "token-id2", + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "ro", + Sequence: 20211202, + UserID: "user-id", + Expiration: time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), + Scopes: []string{"openid"}, + }, + }, + }, + }, + { + name: "preparePersonalAccessTokensQuery sql err", + prepare: preparePersonalAccessTokensQuery, + want: want{ + sqlExpectations: mockQueryErr( + personalAccessTokensStmt, + sql.ErrConnDone, + ), + err: func(err error) (error, bool) { + if !errors.Is(err, sql.ErrConnDone) { + return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false + } + return nil, true + }, + }, + object: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertPrepare(t, tt.prepare, tt.object, tt.want.sqlExpectations, tt.want.err) + }) + } +} diff --git a/internal/repository/user/eventstore.go b/internal/repository/user/eventstore.go index e29ca2a1b2..9f387ed7c2 100644 --- a/internal/repository/user/eventstore.go +++ b/internal/repository/user/eventstore.go @@ -111,5 +111,7 @@ func RegisterEventMappers(es *eventstore.Eventstore) { RegisterFilterEventMapper(MachineAddedEventType, MachineAddedEventMapper). RegisterFilterEventMapper(MachineChangedEventType, MachineChangedEventMapper). RegisterFilterEventMapper(MachineKeyAddedEventType, MachineKeyAddedEventMapper). - RegisterFilterEventMapper(MachineKeyRemovedEventType, MachineKeyRemovedEventMapper) + RegisterFilterEventMapper(MachineKeyRemovedEventType, MachineKeyRemovedEventMapper). + RegisterFilterEventMapper(PersonalAccessTokenAddedType, PersonalAccessTokenAddedEventMapper). + RegisterFilterEventMapper(PersonalAccessTokenRemovedType, PersonalAccessTokenRemovedEventMapper) } diff --git a/internal/repository/user/personal_access_token.go b/internal/repository/user/personal_access_token.go new file mode 100644 index 0000000000..fa8609e30e --- /dev/null +++ b/internal/repository/user/personal_access_token.go @@ -0,0 +1,105 @@ +package user + +import ( + "context" + "encoding/json" + "time" + + "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/eventstore/repository" +) + +const ( + personalAccessTokenEventPrefix = userEventTypePrefix + "pat." + PersonalAccessTokenAddedType = personalAccessTokenEventPrefix + "added" + PersonalAccessTokenRemovedType = personalAccessTokenEventPrefix + "removed" +) + +type PersonalAccessTokenAddedEvent struct { + eventstore.BaseEvent `json:"-"` + + TokenID string `json:"tokenId"` + Expiration time.Time `json:"expiration"` + Scopes []string `json:"scopes"` +} + +func (e *PersonalAccessTokenAddedEvent) Data() interface{} { + return e +} + +func (e *PersonalAccessTokenAddedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func NewPersonalAccessTokenAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + tokenID string, + expiration time.Time, + scopes []string, +) *PersonalAccessTokenAddedEvent { + return &PersonalAccessTokenAddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + PersonalAccessTokenAddedType, + ), + TokenID: tokenID, + Expiration: expiration, + Scopes: scopes, + } +} + +func PersonalAccessTokenAddedEventMapper(event *repository.Event) (eventstore.Event, error) { + tokenAdded := &PersonalAccessTokenAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, tokenAdded) + if err != nil { + return nil, errors.ThrowInternal(err, "USER-Dbges", "unable to unmarshal token added") + } + + return tokenAdded, nil +} + +type PersonalAccessTokenRemovedEvent struct { + eventstore.BaseEvent `json:"-"` + + TokenID string `json:"tokenId"` +} + +func (e *PersonalAccessTokenRemovedEvent) Data() interface{} { + return e +} + +func (e *PersonalAccessTokenRemovedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func NewPersonalAccessTokenRemovedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + tokenID string, +) *PersonalAccessTokenRemovedEvent { + return &PersonalAccessTokenRemovedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + PersonalAccessTokenRemovedType, + ), + TokenID: tokenID, + } +} + +func PersonalAccessTokenRemovedEventMapper(event *repository.Event) (eventstore.Event, error) { + tokenRemoved := &PersonalAccessTokenRemovedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, tokenRemoved) + if err != nil { + return nil, errors.ThrowInternal(err, "USER-Dbneg", "unable to unmarshal token removed") + } + + return tokenRemoved, nil +} diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 29f0e8efac..8752ea4622 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -66,8 +66,11 @@ Errors: Machine: Key: NotFound: Maschinen Key nicht gefunden + PAT: + NotFound: Persönliches Access Token nicht gefunden NotHuman: Der Benutzer muss eine Person sein NotMachine: Der Benutzer muss technisch sein + WrongType: Für diesen Benutzertyp nicht erlaubt NotAllowedToLink: Der Benutzer darf nicht mit einem externen Login Provider verlinkt werden Username: AlreadyExists: Benutzername ist bereits vergeben diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 4753cc51dc..8041c9c19f 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -66,8 +66,11 @@ Errors: Machine: Key: NotFound: Machine key not found + PAT: + NotFound: Personal Access Token not found NotHuman: The User must be personal NotMachine: The User must be technical + WrongType: Not allowed for this user type NotAllowedToLink: User is not allowed to link with external login provider Username: AlreadyExists: Username already taken diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 16912e187d..0e0d88c964 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -66,8 +66,11 @@ Errors: Machine: Key: NotFound: Machine Key non trovato + PAT: + NotFound: Personal Access Token non trovato NotHuman: L'utente deve essere personale NotMachine: L'utente deve essere tecnico + WrongType: Non consentito per questo tipo di utente NotAllowedToLink: L'utente non è autorizzato a collegarsi con un provider di accesso esterno Username: AlreadyExists: Nome utente già preso diff --git a/internal/user/model/token_view.go b/internal/user/model/token_view.go index bd47d65875..1cbb9a2e85 100644 --- a/internal/user/model/token_view.go +++ b/internal/user/model/token_view.go @@ -21,6 +21,7 @@ type TokenView struct { Sequence uint64 PreferredLanguage string RefreshTokenID string + IsPAT bool } type TokenSearchRequest struct { diff --git a/internal/user/repository/view/model/token.go b/internal/user/repository/view/model/token.go index c904b4ec23..bddbd6e198 100644 --- a/internal/user/repository/view/model/token.go +++ b/internal/user/repository/view/model/token.go @@ -39,6 +39,7 @@ type TokenView struct { Sequence uint64 `json:"-" gorm:"column:sequence"` PreferredLanguage string `json:"preferredLanguage" gorm:"column:preferred_language"` RefreshTokenID string `json:"refreshTokenID,omitempty" gorm:"refresh_token_id"` + IsPAT bool `json:"-" gorm:"is_pat"` Deactivated bool `json:"-" gorm:"-"` } @@ -57,6 +58,7 @@ func TokenViewToModel(token *TokenView) *usr_model.TokenView { Sequence: token.Sequence, PreferredLanguage: token.PreferredLanguage, RefreshTokenID: token.RefreshTokenID, + IsPAT: token.IsPAT, } } @@ -107,13 +109,15 @@ func (t *TokenView) AppendEvent(event *es_models.Event) error { t.ChangeDate = event.CreationDate t.Sequence = event.Sequence switch event.Type { - case usr_es_model.UserTokenAdded: + case usr_es_model.UserTokenAdded, + es_models.EventType(user_repo.PersonalAccessTokenAddedType): t.setRootData(event) err := t.setData(event) if err != nil { return err } t.CreationDate = event.CreationDate + t.IsPAT = event.Type == es_models.EventType(user_repo.PersonalAccessTokenAddedType) } return nil } diff --git a/migrations/cockroach/V1.110__personal_access_token.sql b/migrations/cockroach/V1.110__personal_access_token.sql new file mode 100644 index 0000000000..d9e1f86c5e --- /dev/null +++ b/migrations/cockroach/V1.110__personal_access_token.sql @@ -0,0 +1,14 @@ +ALTER TABLE auth.tokens ADD COLUMN is_pat BOOLEAN DEFAULT false NOT NULL; + +CREATE TABLE zitadel.projections.personal_access_tokens ( + id STRING + , creation_date TIMESTAMPTZ NOT NULL + , change_date TIMESTAMPTZ NOT NULL + , resource_owner STRING NOT NULL + , sequence INT8 NOT NULL + , user_id STRING NOT NULL + , expiration TIMESTAMPTZ NOT NULL + , scopes STRING[] + + , PRIMARY KEY (id) +); diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index e454382cdd..48c9c3402e 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -668,7 +668,7 @@ service ManagementService { }; } - // Removed a machine key + // Removes a machine key rpc RemoveMachineKey(RemoveMachineKeyRequest) returns (RemoveMachineKeyResponse) { option (google.api.http) = { delete: "/users/{user_id}/keys/{key_id}" @@ -679,6 +679,53 @@ service ManagementService { }; } + // Returns a personal access token of a (machine) user + rpc GetPersonalAccessTokenByIDs(GetPersonalAccessTokenByIDsRequest) returns (GetPersonalAccessTokenByIDsResponse) { + option (google.api.http) = { + get: "/users/{user_id}/pats/{token_id}" + }; + + option (zitadel.v1.auth_option) = { + permission: "user.read" + }; + } + + // Returns all personal access tokens of a (machine) user which match the query + // Limit should always be set, there is a default limit set by the service + rpc ListPersonalAccessTokens(ListPersonalAccessTokensRequest) returns (ListPersonalAccessTokensResponse) { + option (google.api.http) = { + post: "/users/{user_id}/pats/_search" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "user.read" + }; + } + + // Generates a new personal access token for a machine user, details should be stored after return + rpc AddPersonalAccessToken(AddPersonalAccessTokenRequest) returns (AddPersonalAccessTokenResponse) { + option (google.api.http) = { + post: "/users/{user_id}/pats" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "user.write" + }; + } + + // Removes a personal access token + rpc RemovePersonalAccessToken(RemovePersonalAccessTokenRequest) returns (RemovePersonalAccessTokenResponse) { + option (google.api.http) = { + delete: "/users/{user_id}/pats/{token_id}" + }; + + option (zitadel.v1.auth_option) = { + permission: "user.write" + }; + } + // Lists all identity providers (social logins) which a human has configured (e.g Google, Microsoft, AD, etc..) // Limit should always be set, there is a default limit set by the service rpc ListHumanLinkedIDPs(ListHumanLinkedIDPsRequest) returns (ListHumanLinkedIDPsResponse) { @@ -3398,6 +3445,51 @@ message RemoveMachineKeyResponse { zitadel.v1.ObjectDetails details = 1; } +message GetPersonalAccessTokenByIDsRequest { + string user_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string token_id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message GetPersonalAccessTokenByIDsResponse { + zitadel.user.v1.PersonalAccessToken token = 1; +} + +message ListPersonalAccessTokensRequest { + string user_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + //list limitations and ordering + zitadel.v1.ListQuery query = 2; +} + +message ListPersonalAccessTokensResponse { + zitadel.v1.ListDetails details = 1; + repeated zitadel.user.v1.PersonalAccessToken result = 2; +} + +message AddPersonalAccessTokenRequest { + string user_id = 1 [(validate.rules).string.min_len = 1]; + google.protobuf.Timestamp expiration_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2519-04-01T08:45:00.000000Z\""; + description: "The date the token will expire and no logins will be possible"; + } + ]; +} + +message AddPersonalAccessTokenResponse { + string token_id = 1; + string token = 2; + zitadel.v1.ObjectDetails details = 3; +} + +message RemovePersonalAccessTokenRequest { + string user_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string token_id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message RemovePersonalAccessTokenResponse { + zitadel.v1.ObjectDetails details = 1; +} + message ListHumanLinkedIDPsRequest { string user_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; //list limitations and ordering diff --git a/proto/zitadel/user.proto b/proto/zitadel/user.proto index b6317b41d1..faa811a1f9 100644 --- a/proto/zitadel/user.proto +++ b/proto/zitadel/user.proto @@ -571,6 +571,28 @@ message RefreshToken { ]; } + +message PersonalAccessToken { + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\""; + } + ]; + zitadel.v1.ObjectDetails details = 2; + google.protobuf.Timestamp expiration_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the date the token will expire"; + example: "\"3019-04-01T08:45:00.000000Z\""; + } + ]; + repeated string scopes = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "scopes granted to the token"; + example: "[\"openid\"]"; + } + ]; +} + message UserGrant { string id = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -863,4 +885,4 @@ message UserGrantUserTypeQuery { ]; } -//PLANNED: login name query \ No newline at end of file +//PLANNED: login name query