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:
Livio Amstutz 2022-02-08 09:37:28 +01:00 committed by GitHub
parent 3bf9adece5
commit 699fdaf68e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1838 additions and 30 deletions

View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -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{

View 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,
}
}

View File

@ -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
}

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -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:

View 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
}

View 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
}

View 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)
}
})
}
}

View File

@ -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 {

View 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
}

View File

@ -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
}

View File

@ -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"]))

View 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
}

View 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)
})
}
}

View 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
}
}

View 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)
})
}
}

View File

@ -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)
}

View 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
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -21,6 +21,7 @@ type TokenView struct {
Sequence uint64
PreferredLanguage string
RefreshTokenID string
IsPAT bool
}
type TokenSearchRequest struct {

View File

@ -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
}

View 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)
);

View File

@ -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

View File

@ -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