mirror of
https://github.com/zitadel/zitadel.git
synced 2025-01-06 07:27:41 +00:00
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>
This commit is contained in:
parent
3bf9adece5
commit
699fdaf68e
@ -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<br /> |
|
||||
| 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<br /> string.max_len: 200<br /> |
|
||||
| token_id | string | - | string.min_len: 1<br /> string.max_len: 200<br /> |
|
||||
|
||||
|
||||
|
||||
|
||||
### 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<br /> string.max_len: 200<br /> |
|
||||
| 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<br /> string.max_len: 200<br /> |
|
||||
| token_id | string | - | string.min_len: 1<br /> string.max_len: 200<br /> |
|
||||
|
||||
|
||||
|
||||
|
||||
### RemovePersonalAccessTokenResponse
|
||||
|
||||
|
||||
|
||||
| Field | Type | Description | Validation |
|
||||
| ----- | ---- | ----------- | ----------- |
|
||||
| details | zitadel.v1.ObjectDetails | - | |
|
||||
|
||||
|
||||
|
||||
|
||||
### RemoveProjectGrantMemberRequest
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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{
|
||||
|
25
internal/api/grpc/user/machine_token.go
Normal file
25
internal/api/grpc/user/machine_token.go
Normal file
@ -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,
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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:
|
||||
|
92
internal/command/user_personal_access_token.go
Normal file
92
internal/command/user_personal_access_token.go
Normal file
@ -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
|
||||
}
|
81
internal/command/user_personal_access_token_model.go
Normal file
81
internal/command/user_personal_access_token_model.go
Normal file
@ -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
|
||||
}
|
297
internal/command/user_personal_access_token_test.go
Normal file
297
internal/command/user_personal_access_token_test.go
Normal file
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
36
internal/domain/expiration.go
Normal file
36
internal/domain/expiration.go
Normal file
@ -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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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"]))
|
||||
|
112
internal/query/projection/user_personal_access_token.go
Normal file
112
internal/query/projection/user_personal_access_token.go
Normal file
@ -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
|
||||
}
|
128
internal/query/projection/user_personal_access_token_test.go
Normal file
128
internal/query/projection/user_personal_access_token_test.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
219
internal/query/user_personal_access_token.go
Normal file
219
internal/query/user_personal_access_token.go
Normal file
@ -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
|
||||
}
|
||||
}
|
271
internal/query/user_personal_access_token_test.go
Normal file
271
internal/query/user_personal_access_token_test.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
105
internal/repository/user/personal_access_token.go
Normal file
105
internal/repository/user/personal_access_token.go
Normal file
@ -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
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -21,6 +21,7 @@ type TokenView struct {
|
||||
Sequence uint64
|
||||
PreferredLanguage string
|
||||
RefreshTokenID string
|
||||
IsPAT bool
|
||||
}
|
||||
|
||||
type TokenSearchRequest struct {
|
||||
|
@ -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
|
||||
}
|
||||
|
14
migrations/cockroach/V1.110__personal_access_token.sql
Normal file
14
migrations/cockroach/V1.110__personal_access_token.sql
Normal file
@ -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)
|
||||
);
|
@ -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
|
||||
|
@ -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
|
||||
//PLANNED: login name query
|
||||
|
Loading…
x
Reference in New Issue
Block a user