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