diff --git a/API_DESIGN.md b/API_DESIGN.md index 9e77657ab0..11b7766a49 100644 --- a/API_DESIGN.md +++ b/API_DESIGN.md @@ -206,6 +206,8 @@ The same applies to messages that are returned by multiple resources. For example, information about the `User` might be different when managing the user resource itself than when it's returned as part of an authorization or a manager role, where only limited information is needed. +On the other hand, types that always follow the same pattern and are used in multiple resources, such as `IDFilter`, `TimestampFilter` or `InIDsFilter` SHOULD be globalized and reused. + ##### Re-using messages Prevent reusing messages for the creation and the retrieval of a resource. @@ -271,7 +273,7 @@ Additionally, state changes, specific actions or operations that do not fit into The API uses OAuth 2 for authorization. There are corresponding middlewares that check the access token for validity and automatically return an error if the token is invalid. -Permissions grated to the user might be organization specific and can therefore only be checked based on the queried resource. +Permissions granted to the user might be organization specific and can therefore only be checked based on the queried resource. In such case, the API does not check the permissions itself but relies on the checks of the functions that are called by the API. If the permission can be checked by the API itself, e.g. if the permission is instance wide, it can be annotated on the endpoint in the proto file (see below). In any case, the required permissions need to be documented in the [API documentation](#documentation). diff --git a/cmd/start/start.go b/cmd/start/start.go index 2fc1fb8413..8820480f0c 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -461,7 +461,7 @@ func startAPIs( if err := apis.RegisterService(ctx, user_v2beta.CreateServer(commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(), idp.SAMLRootURL(), assets.AssetAPI(), permissionCheck)); err != nil { return nil, err } - if err := apis.RegisterService(ctx, user_v2.CreateServer(commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(), idp.SAMLRootURL(), assets.AssetAPI(), permissionCheck)); err != nil { + if err := apis.RegisterService(ctx, user_v2.CreateServer(config.SystemDefaults, commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(), idp.SAMLRootURL(), assets.AssetAPI(), permissionCheck)); err != nil { return nil, err } if err := apis.RegisterService(ctx, session_v2beta.CreateServer(commands, queries, permissionCheck)); err != nil { diff --git a/go.mod b/go.mod index c1cbf2dd77..21a7fe9f16 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/go-webauthn/webauthn v0.10.2 github.com/goccy/go-json v0.10.5 github.com/golang/protobuf v1.5.4 + github.com/google/go-cmp v0.7.0 github.com/gorilla/csrf v1.7.2 github.com/gorilla/mux v1.8.1 github.com/gorilla/schema v1.4.1 diff --git a/internal/api/grpc/admin/import.go b/internal/api/grpc/admin/import.go index 119afe9fc0..41a1e39081 100644 --- a/internal/api/grpc/admin/import.go +++ b/internal/api/grpc/admin/import.go @@ -510,7 +510,7 @@ func importMachineUsers(ctx context.Context, s *Server, errors *[]*admin_pb.Impo } for _, user := range org.GetMachineUsers() { logging.Debugf("import user: %s", user.GetUserId()) - _, err := s.command.AddMachine(ctx, management.AddMachineUserRequestToCommand(user.GetUser(), org.GetOrgId())) + _, err := s.command.AddMachine(ctx, management.AddMachineUserRequestToCommand(user.GetUser(), org.GetOrgId()), nil) if err != nil { *errors = append(*errors, &admin_pb.ImportDataError{Type: "machine_user", Id: user.GetUserId(), Message: err.Error()}) if isCtxTimeout(ctx) { diff --git a/internal/api/grpc/filter/v2/converter.go b/internal/api/grpc/filter/v2/converter.go new file mode 100644 index 0000000000..7a7d7cd8d7 --- /dev/null +++ b/internal/api/grpc/filter/v2/converter.go @@ -0,0 +1,50 @@ +package filter + +import ( + "fmt" + + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/grpc/filter/v2" +) + +func TimestampMethodPbToQuery(method filter.TimestampFilterMethod) query.TimestampComparison { + switch method { + case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_EQUALS: + return query.TimestampEquals + case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_BEFORE: + return query.TimestampLess + case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_AFTER: + return query.TimestampGreater + case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_BEFORE_OR_EQUALS: + return query.TimestampLessOrEquals + case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_AFTER_OR_EQUALS: + return query.TimestampGreaterOrEquals + default: + return -1 + } +} + +func PaginationPbToQuery(defaults systemdefaults.SystemDefaults, query *filter.PaginationRequest) (offset, limit uint64, asc bool, err error) { + limit = defaults.DefaultQueryLimit + if query == nil { + return 0, limit, asc, nil + } + offset = query.Offset + asc = query.Asc + if defaults.MaxQueryLimit > 0 && uint64(query.Limit) > defaults.MaxQueryLimit { + return 0, 0, false, zerrors.ThrowInvalidArgumentf(fmt.Errorf("given: %d, allowed: %d", query.Limit, defaults.MaxQueryLimit), "QUERY-4M0fs", "Errors.Query.LimitExceeded") + } + if query.Limit > 0 { + limit = uint64(query.Limit) + } + return offset, limit, asc, nil +} + +func QueryToPaginationPb(request query.SearchRequest, response query.SearchResponse) *filter.PaginationResponse { + return &filter.PaginationResponse{ + AppliedLimit: request.Limit, + TotalResult: response.Count, + } +} diff --git a/internal/api/grpc/management/project_application.go b/internal/api/grpc/management/project_application.go index 3a0e1d5f92..ab49905409 100644 --- a/internal/api/grpc/management/project_application.go +++ b/internal/api/grpc/management/project_application.go @@ -271,7 +271,7 @@ func (s *Server) ListAppKeys(ctx context.Context, req *mgmt_pb.ListAppKeysReques if err != nil { return nil, err } - keys, err := s.query.SearchAuthNKeys(ctx, queries, false) + keys, err := s.query.SearchAuthNKeys(ctx, queries, query.JoinFilterApp, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index f318051e63..ae1040cd1e 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -297,7 +297,7 @@ func (s *Server) ImportHumanUser(ctx context.Context, req *mgmt_pb.ImportHumanUs func (s *Server) AddMachineUser(ctx context.Context, req *mgmt_pb.AddMachineUserRequest) (*mgmt_pb.AddMachineUserResponse, error) { machine := AddMachineUserRequestToCommand(req, authz.GetCtxData(ctx).OrgID) - objectDetails, err := s.command.AddMachine(ctx, machine) + objectDetails, err := s.command.AddMachine(ctx, machine, nil) if err != nil { return nil, err } @@ -752,11 +752,11 @@ func (s *Server) GetMachineKeyByIDs(ctx context.Context, req *mgmt_pb.GetMachine } func (s *Server) ListMachineKeys(ctx context.Context, req *mgmt_pb.ListMachineKeysRequest) (*mgmt_pb.ListMachineKeysResponse, error) { - query, err := ListMachineKeysRequestToQuery(ctx, req) + q, err := ListMachineKeysRequestToQuery(ctx, req) if err != nil { return nil, err } - result, err := s.query.SearchAuthNKeys(ctx, query, false) + result, err := s.query.SearchAuthNKeys(ctx, q, query.JoinFilterUserMachine, nil) if err != nil { return nil, err } @@ -774,7 +774,6 @@ func (s *Server) AddMachineKey(ctx context.Context, req *mgmt_pb.AddMachineKeyRe if err != nil { return nil, err } - // Return key details only if the pubkey wasn't supplied, otherwise the user already has // private key locally var keyDetails []byte @@ -821,7 +820,7 @@ func (s *Server) GenerateMachineSecret(ctx context.Context, req *mgmt_pb.Generat } func (s *Server) RemoveMachineSecret(ctx context.Context, req *mgmt_pb.RemoveMachineSecretRequest) (*mgmt_pb.RemoveMachineSecretResponse, error) { - objectDetails, err := s.command.RemoveMachineSecret(ctx, req.UserId, authz.GetCtxData(ctx).OrgID) + objectDetails, err := s.command.RemoveMachineSecret(ctx, req.UserId, authz.GetCtxData(ctx).OrgID, nil) if err != nil { return nil, err } @@ -839,7 +838,7 @@ func (s *Server) GetPersonalAccessTokenByIDs(ctx context.Context, req *mgmt_pb.G if err != nil { return nil, err } - token, err := s.query.PersonalAccessTokenByID(ctx, true, req.TokenId, false, resourceOwner, aggregateID) + token, err := s.query.PersonalAccessTokenByID(ctx, true, req.TokenId, resourceOwner, aggregateID) if err != nil { return nil, err } @@ -853,7 +852,7 @@ func (s *Server) ListPersonalAccessTokens(ctx context.Context, req *mgmt_pb.List if err != nil { return nil, err } - result, err := s.query.SearchPersonalAccessTokens(ctx, queries, false) + result, err := s.query.SearchPersonalAccessTokens(ctx, queries, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/project/v2beta/integration/query_test.go b/internal/api/grpc/project/v2beta/integration/query_test.go index f959bfe2f8..517f103628 100644 --- a/internal/api/grpc/project/v2beta/integration/query_test.go +++ b/internal/api/grpc/project/v2beta/integration/query_test.go @@ -168,8 +168,8 @@ func TestServer_ListProjects(t *testing.T) { orgID := instance.DefaultOrg.GetId() resp := instance.CreateProject(iamOwnerCtx, t, orgID, name, false, false) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{resp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{resp.GetId()}, }, } }, @@ -188,8 +188,8 @@ func TestServer_ListProjects(t *testing.T) { orgID := instance.DefaultOrg.GetId() resp := instance.CreateProject(iamOwnerCtx, t, orgID, name, false, false) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{resp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{resp.GetId()}, }, } }, @@ -208,8 +208,8 @@ func TestServer_ListProjects(t *testing.T) { orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) resp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name, false, false) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{resp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{resp.GetId()}, }, } }, @@ -232,8 +232,8 @@ func TestServer_ListProjects(t *testing.T) { req: &project.ListProjectsRequest{ Filters: []*project.ProjectSearchFilter{ {Filter: &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{"notfound"}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{"notfound"}, }, }, }, @@ -255,8 +255,8 @@ func TestServer_ListProjects(t *testing.T) { orgID := instance.DefaultOrg.GetId() response.Projects[0] = createProject(iamOwnerCtx, instance, t, orgID, false, false) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{response.Projects[0].GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{response.Projects[0].GetId()}, }, } }, @@ -317,8 +317,8 @@ func TestServer_ListProjects(t *testing.T) { response.Projects[1] = createProject(iamOwnerCtx, instance, t, orgID, true, false) response.Projects[0] = createProject(iamOwnerCtx, instance, t, orgID, false, true) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{response.Projects[0].GetId(), response.Projects[1].GetId(), response.Projects[2].GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{response.Projects[0].GetId(), response.Projects[1].GetId(), response.Projects[2].GetId()}, }, } }, @@ -349,8 +349,8 @@ func TestServer_ListProjects(t *testing.T) { resp2 := createProject(iamOwnerCtx, instance, t, orgID, true, false) resp3 := createProject(iamOwnerCtx, instance, t, orgResp.GetOrganizationId(), false, true) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{resp1.GetId(), resp2.GetId(), resp3.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{resp1.GetId(), resp2.GetId(), resp3.GetId()}, }, } @@ -379,8 +379,8 @@ func TestServer_ListProjects(t *testing.T) { projectResp := createProject(iamOwnerCtx, instance, t, orgID, true, true) response.Projects[3] = projectResp request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } response.Projects[2] = createGrantedProject(iamOwnerCtx, instance, t, projectResp) @@ -416,7 +416,7 @@ func TestServer_ListProjects(t *testing.T) { response.Projects[1] = grantedProjectResp response.Projects[0] = createProject(iamOwnerCtx, instance, t, *grantedProjectResp.GrantedOrganizationId, true, true) request.Filters[0].Filter = &project.ProjectSearchFilter_ProjectOrganizationIdFilter{ - ProjectOrganizationIdFilter: &project.ProjectOrganizationIDFilter{ProjectOrganizationId: *grantedProjectResp.GrantedOrganizationId}, + ProjectOrganizationIdFilter: &filter.IDFilter{Id: *grantedProjectResp.GrantedOrganizationId}, } }, req: &project.ListProjectsRequest{ @@ -445,7 +445,7 @@ func TestServer_ListProjects(t *testing.T) { grantedProjectResp := createGrantedProject(iamOwnerCtx, instance, t, projectResp) response.Projects[0] = createProject(iamOwnerCtx, instance, t, *grantedProjectResp.GrantedOrganizationId, true, true) request.Filters[0].Filter = &project.ProjectSearchFilter_ProjectResourceOwnerFilter{ - ProjectResourceOwnerFilter: &project.ProjectResourceOwnerFilter{ProjectResourceOwner: *grantedProjectResp.GrantedOrganizationId}, + ProjectResourceOwnerFilter: &filter.IDFilter{Id: *grantedProjectResp.GrantedOrganizationId}, } }, req: &project.ListProjectsRequest{ @@ -513,8 +513,8 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { resp := createProject(iamOwnerCtx, instancePermissionV2, t, orgID, false, false) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{resp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{resp.GetId()}, }, } }, @@ -531,8 +531,8 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { resp := createProject(iamOwnerCtx, instancePermissionV2, t, orgID, false, false) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{resp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{resp.GetId()}, }, } }, @@ -550,8 +550,8 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { orgResp := instancePermissionV2.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) resp := createProject(iamOwnerCtx, instancePermissionV2, t, orgResp.GetOrganizationId(), false, false) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{resp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{resp.GetId()}, }, } }, @@ -574,8 +574,8 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { req: &project.ListProjectsRequest{ Filters: []*project.ProjectSearchFilter{ {Filter: &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{"notfound"}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{"notfound"}, }, }, }, @@ -596,8 +596,8 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { response.Projects[0] = createProject(iamOwnerCtx, instancePermissionV2, t, orgID, false, false) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{response.Projects[0].GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{response.Projects[0].GetId()}, }, } }, @@ -650,8 +650,8 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { response.Projects[1] = createProject(iamOwnerCtx, instancePermissionV2, t, orgID, true, false) response.Projects[0] = createProject(iamOwnerCtx, instancePermissionV2, t, orgID, false, true) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{response.Projects[0].GetId(), response.Projects[1].GetId(), response.Projects[2].GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{response.Projects[0].GetId(), response.Projects[1].GetId(), response.Projects[2].GetId()}, }, } }, @@ -679,8 +679,8 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { projectResp := createProject(iamOwnerCtx, instancePermissionV2, t, orgID, true, true) response.Projects[3] = projectResp request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } response.Projects[2] = createGrantedProject(iamOwnerCtx, instancePermissionV2, t, projectResp) @@ -715,7 +715,7 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { response.Projects[1] = grantedProjectResp response.Projects[0] = createProject(iamOwnerCtx, instancePermissionV2, t, *grantedProjectResp.GrantedOrganizationId, true, true) request.Filters[0].Filter = &project.ProjectSearchFilter_ProjectOrganizationIdFilter{ - ProjectOrganizationIdFilter: &project.ProjectOrganizationIDFilter{ProjectOrganizationId: *grantedProjectResp.GrantedOrganizationId}, + ProjectOrganizationIdFilter: &filter.IDFilter{Id: *grantedProjectResp.GrantedOrganizationId}, } }, req: &project.ListProjectsRequest{ @@ -743,7 +743,7 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { grantedProjectResp := createGrantedProject(iamOwnerCtx, instancePermissionV2, t, projectResp) response.Projects[0] = createProject(iamOwnerCtx, instancePermissionV2, t, *grantedProjectResp.GrantedOrganizationId, true, true) request.Filters[0].Filter = &project.ProjectSearchFilter_ProjectResourceOwnerFilter{ - ProjectResourceOwnerFilter: &project.ProjectResourceOwnerFilter{ProjectResourceOwner: *grantedProjectResp.GrantedOrganizationId}, + ProjectResourceOwnerFilter: &filter.IDFilter{Id: *grantedProjectResp.GrantedOrganizationId}, } }, req: &project.ListProjectsRequest{ @@ -770,8 +770,8 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { resp2 := createProject(iamOwnerCtx, instancePermissionV2, t, orgID, true, false) resp3 := createProject(iamOwnerCtx, instancePermissionV2, t, orgResp.GetOrganizationId(), false, true) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{resp1.GetId(), resp2.GetId(), resp3.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{resp1.GetId(), resp2.GetId(), resp3.GetId()}, }, } @@ -882,15 +882,13 @@ func TestServer_ListProjectGrants(t *testing.T) { dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) request.Filters[1].Filter = &project.ProjectGrantSearchFilter_ProjectGrantResourceOwnerFilter{ - ProjectGrantResourceOwnerFilter: &project.ProjectGrantResourceOwnerFilter{ - ProjectGrantResourceOwner: grantedOrg.GetOrganizationId(), - }, + ProjectGrantResourceOwnerFilter: &filter.IDFilter{Id: grantedOrg.GetOrganizationId()}, } instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) @@ -908,15 +906,13 @@ func TestServer_ListProjectGrants(t *testing.T) { dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) request.Filters[1].Filter = &project.ProjectGrantSearchFilter_ProjectGrantResourceOwnerFilter{ - ProjectGrantResourceOwnerFilter: &project.ProjectGrantResourceOwnerFilter{ - ProjectGrantResourceOwner: grantedOrg.GetOrganizationId(), - }, + ProjectGrantResourceOwnerFilter: &filter.IDFilter{Id: grantedOrg.GetOrganizationId()}, } instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) @@ -934,8 +930,8 @@ func TestServer_ListProjectGrants(t *testing.T) { req: &project.ListProjectGrantsRequest{ Filters: []*project.ProjectGrantSearchFilter{ {Filter: &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{"notfound"}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{"notfound"}, }, }, }, @@ -958,8 +954,8 @@ func TestServer_ListProjectGrants(t *testing.T) { orgID := instance.DefaultOrg.GetId() projectResp := instance.CreateProject(iamOwnerCtx, t, orgID, name, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } @@ -988,8 +984,8 @@ func TestServer_ListProjectGrants(t *testing.T) { orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } @@ -1016,8 +1012,8 @@ func TestServer_ListProjectGrants(t *testing.T) { orgID := instance.DefaultOrg.GetId() projectResp := instance.CreateProject(iamOwnerCtx, t, orgID, name, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } @@ -1053,8 +1049,8 @@ func TestServer_ListProjectGrants(t *testing.T) { project2Resp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name2, false, false) project3Resp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name3, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{project1Resp.GetId(), project2Resp.GetId(), project3Resp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{project1Resp.GetId(), project2Resp.GetId(), project3Resp.GetId()}, }, } @@ -1084,8 +1080,8 @@ func TestServer_ListProjectGrants(t *testing.T) { orgID := instance.DefaultOrg.GetId() projectResp := instance.CreateProject(iamOwnerCtx, t, orgID, name, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } projectRoleResp := addProjectRole(iamOwnerCtx, instance, t, projectResp.GetId()) @@ -1114,8 +1110,8 @@ func TestServer_ListProjectGrants(t *testing.T) { orgID := instance.DefaultOrg.GetId() projectResp := instance.CreateProject(iamOwnerCtx, t, orgID, name, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } projectRoleResp := addProjectRole(iamOwnerCtx, instance, t, projectResp.GetId()) @@ -1189,15 +1185,13 @@ func TestServer_ListProjectGrants_PermissionV2(t *testing.T) { dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, instancePermissionV2.DefaultOrg.GetId(), gofakeit.AppName(), false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } grantedOrg := instancePermissionV2.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) request.Filters[1].Filter = &project.ProjectGrantSearchFilter_ProjectGrantResourceOwnerFilter{ - ProjectGrantResourceOwnerFilter: &project.ProjectGrantResourceOwnerFilter{ - ProjectGrantResourceOwner: grantedOrg.GetOrganizationId(), - }, + ProjectGrantResourceOwnerFilter: &filter.IDFilter{Id: grantedOrg.GetOrganizationId()}, } instancePermissionV2.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) @@ -1215,15 +1209,13 @@ func TestServer_ListProjectGrants_PermissionV2(t *testing.T) { dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, instancePermissionV2.DefaultOrg.GetId(), gofakeit.AppName(), false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } grantedOrg := instancePermissionV2.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) request.Filters[1].Filter = &project.ProjectGrantSearchFilter_ProjectGrantResourceOwnerFilter{ - ProjectGrantResourceOwnerFilter: &project.ProjectGrantResourceOwnerFilter{ - ProjectGrantResourceOwner: grantedOrg.GetOrganizationId(), - }, + ProjectGrantResourceOwnerFilter: &filter.IDFilter{Id: grantedOrg.GetOrganizationId()}, } instancePermissionV2.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) @@ -1243,8 +1235,8 @@ func TestServer_ListProjectGrants_PermissionV2(t *testing.T) { orgID := instancePermissionV2.DefaultOrg.GetId() projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, orgID, name, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } @@ -1273,8 +1265,8 @@ func TestServer_ListProjectGrants_PermissionV2(t *testing.T) { orgResp := instancePermissionV2.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } @@ -1301,8 +1293,8 @@ func TestServer_ListProjectGrants_PermissionV2(t *testing.T) { orgID := instancePermissionV2.DefaultOrg.GetId() projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, orgID, name, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } @@ -1338,8 +1330,8 @@ func TestServer_ListProjectGrants_PermissionV2(t *testing.T) { project2Resp := instancePermissionV2.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name2, false, false) project3Resp := instancePermissionV2.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name3, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{project1Resp.GetId(), project2Resp.GetId(), project3Resp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{project1Resp.GetId(), project2Resp.GetId(), project3Resp.GetId()}, }, } diff --git a/internal/api/grpc/project/v2beta/query.go b/internal/api/grpc/project/v2beta/query.go index 1cdf9eefbd..42b69a480e 100644 --- a/internal/api/grpc/project/v2beta/query.go +++ b/internal/api/grpc/project/v2beta/query.go @@ -9,6 +9,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" + filter_pb "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" project_pb "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" ) @@ -109,20 +110,20 @@ func projectNameFilterToQuery(q *project_pb.ProjectNameFilter) (query.SearchQuer return query.NewGrantedProjectNameSearchQuery(filter.TextMethodPbToQuery(q.Method), q.GetProjectName()) } -func projectInIDsFilterToQuery(q *project_pb.InProjectIDsFilter) (query.SearchQuery, error) { - return query.NewGrantedProjectIDSearchQuery(q.ProjectIds) +func projectInIDsFilterToQuery(q *filter_pb.InIDsFilter) (query.SearchQuery, error) { + return query.NewGrantedProjectIDSearchQuery(q.Ids) } -func projectResourceOwnerFilterToQuery(q *project_pb.ProjectResourceOwnerFilter) (query.SearchQuery, error) { - return query.NewGrantedProjectResourceOwnerSearchQuery(q.ProjectResourceOwner) +func projectResourceOwnerFilterToQuery(q *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewGrantedProjectResourceOwnerSearchQuery(q.Id) } -func projectOrganizationIDFilterToQuery(q *project_pb.ProjectOrganizationIDFilter) (query.SearchQuery, error) { - return query.NewGrantedProjectOrganizationIDSearchQuery(q.ProjectOrganizationId) +func projectOrganizationIDFilterToQuery(q *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewGrantedProjectOrganizationIDSearchQuery(q.Id) } -func projectGrantResourceOwnerFilterToQuery(q *project_pb.ProjectGrantResourceOwnerFilter) (query.SearchQuery, error) { - return query.NewGrantedProjectGrantResourceOwnerSearchQuery(q.ProjectGrantResourceOwner) +func projectGrantResourceOwnerFilterToQuery(q *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewGrantedProjectGrantResourceOwnerSearchQuery(q.Id) } func grantedProjectsToPb(projects []*query.GrantedProject) []*project_pb.Project { @@ -283,11 +284,11 @@ func projectGrantFilterToModel(filter *project_pb.ProjectGrantSearchFilter) (que case *project_pb.ProjectGrantSearchFilter_RoleKeyFilter: return query.NewProjectGrantRoleKeySearchQuery(q.RoleKeyFilter.Key) case *project_pb.ProjectGrantSearchFilter_InProjectIdsFilter: - return query.NewProjectGrantProjectIDsSearchQuery(q.InProjectIdsFilter.ProjectIds) + return query.NewProjectGrantProjectIDsSearchQuery(q.InProjectIdsFilter.Ids) case *project_pb.ProjectGrantSearchFilter_ProjectResourceOwnerFilter: - return query.NewProjectGrantResourceOwnerSearchQuery(q.ProjectResourceOwnerFilter.ProjectResourceOwner) + return query.NewProjectGrantResourceOwnerSearchQuery(q.ProjectResourceOwnerFilter.Id) case *project_pb.ProjectGrantSearchFilter_ProjectGrantResourceOwnerFilter: - return query.NewProjectGrantGrantedOrgIDSearchQuery(q.ProjectGrantResourceOwnerFilter.ProjectGrantResourceOwner) + return query.NewProjectGrantGrantedOrgIDSearchQuery(q.ProjectGrantResourceOwnerFilter.Id) default: return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-M099f", "List.Query.Invalid") } diff --git a/internal/api/grpc/user/v2/human.go b/internal/api/grpc/user/v2/human.go new file mode 100644 index 0000000000..d8a0891396 --- /dev/null +++ b/internal/api/grpc/user/v2/human.go @@ -0,0 +1,187 @@ +package user + +import ( + "context" + "io" + + "golang.org/x/text/language" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" + legacyobject "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func (s *Server) createUserTypeHuman(ctx context.Context, humanPb *user.CreateUserRequest_Human, orgId string, userName, userId *string) (*user.CreateUserResponse, error) { + addHumanPb := &user.AddHumanUserRequest{ + Username: userName, + UserId: userId, + Organization: &legacyobject.Organization{ + Org: &legacyobject.Organization_OrgId{OrgId: orgId}, + }, + Profile: humanPb.Profile, + Email: humanPb.Email, + Phone: humanPb.Phone, + IdpLinks: humanPb.IdpLinks, + TotpSecret: humanPb.TotpSecret, + } + switch pwType := humanPb.GetPasswordType().(type) { + case *user.CreateUserRequest_Human_HashedPassword: + addHumanPb.PasswordType = &user.AddHumanUserRequest_HashedPassword{ + HashedPassword: pwType.HashedPassword, + } + case *user.CreateUserRequest_Human_Password: + addHumanPb.PasswordType = &user.AddHumanUserRequest_Password{ + Password: pwType.Password, + } + default: + // optional password is not set + } + newHuman, err := AddUserRequestToAddHuman(addHumanPb) + if err != nil { + return nil, err + } + if err = s.command.AddUserHuman( + ctx, + orgId, + newHuman, + false, + s.userCodeAlg, + ); err != nil { + return nil, err + } + return &user.CreateUserResponse{ + Id: newHuman.ID, + CreationDate: timestamppb.New(newHuman.Details.EventDate), + EmailCode: newHuman.EmailCode, + PhoneCode: newHuman.PhoneCode, + }, nil +} + +func (s *Server) updateUserTypeHuman(ctx context.Context, humanPb *user.UpdateUserRequest_Human, userId string, userName *string) (*user.UpdateUserResponse, error) { + cmd, err := updateHumanUserToCommand(userId, userName, humanPb) + if err != nil { + return nil, err + } + if err = s.command.ChangeUserHuman(ctx, cmd, s.userCodeAlg); err != nil { + return nil, err + } + return &user.UpdateUserResponse{ + ChangeDate: timestamppb.New(cmd.Details.EventDate), + EmailCode: cmd.EmailCode, + PhoneCode: cmd.PhoneCode, + }, nil +} + +func updateHumanUserToCommand(userId string, userName *string, human *user.UpdateUserRequest_Human) (*command.ChangeHuman, error) { + phone := human.GetPhone() + if phone != nil && phone.Phone == "" && phone.GetVerification() != nil { + return nil, zerrors.ThrowInvalidArgument(nil, "USERv2-4f3d6", "Errors.User.Phone.VerifyingRemovalIsNotSupported") + } + email, err := setHumanEmailToEmail(human.Email, userId) + if err != nil { + return nil, err + } + return &command.ChangeHuman{ + ID: userId, + Username: userName, + Profile: SetHumanProfileToProfile(human.Profile), + Email: email, + Phone: setHumanPhoneToPhone(human.Phone, true), + Password: setHumanPasswordToPassword(human.Password), + }, nil +} + +func updateHumanUserRequestToChangeHuman(req *user.UpdateHumanUserRequest) (*command.ChangeHuman, error) { + email, err := setHumanEmailToEmail(req.Email, req.GetUserId()) + if err != nil { + return nil, err + } + changeHuman := &command.ChangeHuman{ + ID: req.GetUserId(), + Username: req.Username, + Email: email, + Phone: setHumanPhoneToPhone(req.Phone, false), + Password: setHumanPasswordToPassword(req.Password), + } + if profile := req.GetProfile(); profile != nil { + var firstName *string + if profile.GivenName != "" { + firstName = &profile.GivenName + } + var lastName *string + if profile.FamilyName != "" { + lastName = &profile.FamilyName + } + changeHuman.Profile = SetHumanProfileToProfile(&user.UpdateUserRequest_Human_Profile{ + GivenName: firstName, + FamilyName: lastName, + NickName: profile.NickName, + DisplayName: profile.DisplayName, + PreferredLanguage: profile.PreferredLanguage, + Gender: profile.Gender, + }) + } + return changeHuman, nil +} + +func SetHumanProfileToProfile(profile *user.UpdateUserRequest_Human_Profile) *command.Profile { + if profile == nil { + return nil + } + return &command.Profile{ + FirstName: profile.GivenName, + LastName: profile.FamilyName, + NickName: profile.NickName, + DisplayName: profile.DisplayName, + PreferredLanguage: ifNotNilPtr(profile.PreferredLanguage, language.Make), + Gender: ifNotNilPtr(profile.Gender, genderToDomain), + } +} + +func setHumanEmailToEmail(email *user.SetHumanEmail, userID string) (*command.Email, error) { + if email == nil { + return nil, nil + } + var urlTemplate string + if email.GetSendCode() != nil && email.GetSendCode().UrlTemplate != nil { + urlTemplate = *email.GetSendCode().UrlTemplate + if err := domain.RenderConfirmURLTemplate(io.Discard, urlTemplate, userID, "code", "orgID"); err != nil { + return nil, err + } + } + return &command.Email{ + Address: domain.EmailAddress(email.Email), + Verified: email.GetIsVerified(), + ReturnCode: email.GetReturnCode() != nil, + URLTemplate: urlTemplate, + }, nil +} + +func setHumanPhoneToPhone(phone *user.SetHumanPhone, withRemove bool) *command.Phone { + if phone == nil { + return nil + } + number := phone.GetPhone() + return &command.Phone{ + Number: domain.PhoneNumber(number), + Verified: phone.GetIsVerified(), + ReturnCode: phone.GetReturnCode() != nil, + Remove: withRemove && number == "", + } +} + +func setHumanPasswordToPassword(password *user.SetPassword) *command.Password { + if password == nil { + return nil + } + return &command.Password{ + PasswordCode: password.GetVerificationCode(), + OldPassword: password.GetCurrentPassword(), + Password: password.GetPassword().GetPassword(), + EncodedPasswordHash: password.GetHashedPassword().GetHash(), + ChangeRequired: password.GetPassword().GetChangeRequired() || password.GetHashedPassword().GetChangeRequired(), + } +} diff --git a/internal/api/grpc/user/v2/human_test.go b/internal/api/grpc/user/v2/human_test.go new file mode 100644 index 0000000000..52e5371dcc --- /dev/null +++ b/internal/api/grpc/user/v2/human_test.go @@ -0,0 +1,254 @@ +package user + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func Test_patchHumanUserToCommand(t *testing.T) { + type args struct { + userId string + userName *string + human *user.UpdateUserRequest_Human + } + tests := []struct { + name string + args args + want *command.ChangeHuman + wantErr assert.ErrorAssertionFunc + }{{ + name: "single property", + args: args{ + userId: "userId", + human: &user.UpdateUserRequest_Human{ + Profile: &user.UpdateUserRequest_Human_Profile{ + GivenName: gu.Ptr("givenName"), + }, + }, + }, + want: &command.ChangeHuman{ + ID: "userId", + Profile: &command.Profile{ + FirstName: gu.Ptr("givenName"), + }, + }, + wantErr: assert.NoError, + }, { + name: "all properties", + args: args{ + userId: "userId", + userName: gu.Ptr("userName"), + human: &user.UpdateUserRequest_Human{ + Profile: &user.UpdateUserRequest_Human_Profile{ + GivenName: gu.Ptr("givenName"), + FamilyName: gu.Ptr("familyName"), + NickName: gu.Ptr("nickName"), + DisplayName: gu.Ptr("displayName"), + PreferredLanguage: gu.Ptr("en-US"), + Gender: gu.Ptr(user.Gender_GENDER_FEMALE), + }, + Email: &user.SetHumanEmail{ + Email: "email@example.com", + Verification: &user.SetHumanEmail_IsVerified{ + IsVerified: true, + }, + }, + Phone: &user.SetHumanPhone{ + Phone: "+123456789", + Verification: &user.SetHumanPhone_IsVerified{ + IsVerified: true, + }, + }, + Password: &user.SetPassword{ + Verification: &user.SetPassword_CurrentPassword{ + CurrentPassword: "currentPassword", + }, + PasswordType: &user.SetPassword_Password{ + Password: &user.Password{ + Password: "newPassword", + ChangeRequired: true, + }, + }, + }, + }, + }, + want: &command.ChangeHuman{ + ID: "userId", + Username: gu.Ptr("userName"), + Profile: &command.Profile{ + FirstName: gu.Ptr("givenName"), + LastName: gu.Ptr("familyName"), + NickName: gu.Ptr("nickName"), + DisplayName: gu.Ptr("displayName"), + PreferredLanguage: &language.AmericanEnglish, + Gender: gu.Ptr(domain.GenderFemale), + }, + Email: &command.Email{ + Address: "email@example.com", + Verified: true, + }, + Phone: &command.Phone{ + Number: "+123456789", + Verified: true, + }, + Password: &command.Password{ + OldPassword: "currentPassword", + Password: "newPassword", + ChangeRequired: true, + }, + }, + wantErr: assert.NoError, + }, { + name: "set email and request code", + args: args{ + userId: "userId", + human: &user.UpdateUserRequest_Human{ + Email: &user.SetHumanEmail{ + Email: "email@example.com", + Verification: &user.SetHumanEmail_ReturnCode{ + ReturnCode: &user.ReturnEmailVerificationCode{}, + }, + }, + }, + }, + want: &command.ChangeHuman{ + ID: "userId", + Email: &command.Email{ + Address: "email@example.com", + ReturnCode: true, + }, + }, + wantErr: assert.NoError, + }, { + name: "set email and send code", + args: args{ + userId: "userId", + human: &user.UpdateUserRequest_Human{ + Email: &user.SetHumanEmail{ + Email: "email@example.com", + Verification: &user.SetHumanEmail_SendCode{ + SendCode: &user.SendEmailVerificationCode{}, + }, + }, + }, + }, + want: &command.ChangeHuman{ + ID: "userId", + Email: &command.Email{ + Address: "email@example.com", + }, + }, + wantErr: assert.NoError, + }, { + name: "set email and send code with template", + args: args{ + userId: "userId", + human: &user.UpdateUserRequest_Human{ + Email: &user.SetHumanEmail{ + Email: "email@example.com", + Verification: &user.SetHumanEmail_SendCode{ + SendCode: &user.SendEmailVerificationCode{ + UrlTemplate: gu.Ptr("Code: {{.Code}}"), + }, + }, + }, + }, + }, + want: &command.ChangeHuman{ + ID: "userId", + Email: &command.Email{ + Address: "email@example.com", + URLTemplate: "Code: {{.Code}}", + }, + }, + wantErr: assert.NoError, + }, { + name: "set phone and request code", + args: args{ + userId: "userId", + human: &user.UpdateUserRequest_Human{ + Phone: &user.SetHumanPhone{ + Phone: "+123456789", + Verification: &user.SetHumanPhone_ReturnCode{ + ReturnCode: &user.ReturnPhoneVerificationCode{}, + }, + }, + }, + }, + want: &command.ChangeHuman{ + ID: "userId", + Phone: &command.Phone{ + Number: "+123456789", + ReturnCode: true, + }, + }, + wantErr: assert.NoError, + }, { + name: "set phone and send code", + args: args{ + userId: "userId", + human: &user.UpdateUserRequest_Human{ + Phone: &user.SetHumanPhone{ + Phone: "+123456789", + Verification: &user.SetHumanPhone_SendCode{ + SendCode: &user.SendPhoneVerificationCode{}, + }, + }, + }, + }, + want: &command.ChangeHuman{ + ID: "userId", + Phone: &command.Phone{ + Number: "+123456789", + }, + }, + wantErr: assert.NoError, + }, { + name: "remove phone, ok", + args: args{ + userId: "userId", + human: &user.UpdateUserRequest_Human{ + Phone: &user.SetHumanPhone{}, + }, + }, + want: &command.ChangeHuman{ + ID: "userId", + Phone: &command.Phone{ + Remove: true, + }, + }, + wantErr: assert.NoError, + }, { + name: "remove phone with verification, error", + args: args{ + userId: "userId", + human: &user.UpdateUserRequest_Human{ + Phone: &user.SetHumanPhone{ + Verification: &user.SetHumanPhone_ReturnCode{}, + }, + }, + }, + wantErr: assert.Error, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := updateHumanUserToCommand(tt.args.userId, tt.args.userName, tt.args.human) + if !tt.wantErr(t, err, fmt.Sprintf("patchHumanUserToCommand(%v, %v, %v)", tt.args.userId, tt.args.userName, tt.args.human)) { + return + } + if diff := cmp.Diff(tt.want, got, cmpopts.EquateComparable(language.Tag{})); diff != "" { + t.Errorf("patchHumanUserToCommand() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/api/grpc/user/v2/integration_test/email_test.go b/internal/api/grpc/user/v2/integration_test/email_test.go index ad63c2ce5e..ad68ef5c5a 100644 --- a/internal/api/grpc/user/v2/integration_test/email_test.go +++ b/internal/api/grpc/user/v2/integration_test/email_test.go @@ -10,13 +10,13 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" - + "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func TestServer_SetEmail(t *testing.T) { +func TestServer_Deprecated_SetEmail(t *testing.T) { userID := Instance.CreateHumanUser(CTX).GetUserId() tests := []struct { diff --git a/internal/api/grpc/user/v2/integration_test/key_test.go b/internal/api/grpc/user/v2/integration_test/key_test.go new file mode 100644 index 0000000000..e85903b2cb --- /dev/null +++ b/internal/api/grpc/user/v2/integration_test/key_test.go @@ -0,0 +1,659 @@ +//go:build integration + +package user_test + +import ( + "context" + "fmt" + "slices" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/testing/protocmp" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func TestServer_AddKey(t *testing.T) { + resp := Instance.CreateUserTypeMachine(IamCTX) + userId := resp.GetId() + expirationDate := timestamppb.New(time.Now().Add(time.Hour * 24)) + type args struct { + req *user.AddKeyRequest + prepare func(request *user.AddKeyRequest) error + } + tests := []struct { + name string + args args + wantErr bool + wantEmtpyKey bool + }{ + { + name: "add key, user not existing", + args: args{ + &user.AddKeyRequest{ + UserId: "notexisting", + ExpirationDate: expirationDate, + }, + func(request *user.AddKeyRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "generate key pair, ok", + args: args{ + &user.AddKeyRequest{ + ExpirationDate: expirationDate, + }, + func(request *user.AddKeyRequest) error { + request.UserId = userId + return nil + }, + }, + }, + { + name: "add valid public key, ok", + args: args{ + &user.AddKeyRequest{ + ExpirationDate: expirationDate, + // This is the public key of the tester system user. This must be valid. + PublicKey: []byte(` +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzi+FFSJL7f5yw4KTwzgM +P34ePGycm/M+kT0M7V4Cgx5V3EaDIvTQKTLfBaEB45zb9LtjIXzDw0rXRoS2hO6t +h+CYQCz3KCvh09C0IzxZiB2IS3H/aT+5Bx9EFY+vnAkZjccbyG5YNRvmtOlnvIeI +H7qZ0tEwkPfF5GEZNPJPtmy3UGV7iofdVQS1xRj73+aMw5rvH4D8IdyiAC3VekIb +pt0Vj0SUX3DwKtog337BzTiPk3aXRF0sbFhQoqdJRI8NqgZjCwjq9yfI5tyxYswn ++JGzHGdHvW3idODlmwEt5K2pasiRIWK2OGfq+w0EcltQHabuqEPgZlmhCkRdNfix +BwIDAQAB +-----END PUBLIC KEY----- +`), + }, + func(request *user.AddKeyRequest) error { + request.UserId = userId + return nil + }, + }, + wantEmtpyKey: true, + }, + { + name: "add invalid public key, error", + args: args{ + &user.AddKeyRequest{ + ExpirationDate: expirationDate, + PublicKey: []byte(` +-----BEGIN PUBLIC KEY----- +abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 +-----END PUBLIC KEY----- +`), + }, + func(request *user.AddKeyRequest) error { + request.UserId = userId + return nil + }, + }, + wantErr: true, + }, + { + name: "add key human, error", + args: args{ + &user.AddKeyRequest{ + ExpirationDate: expirationDate, + }, + func(request *user.AddKeyRequest) error { + resp := Instance.CreateUserTypeHuman(IamCTX) + request.UserId = resp.Id + return nil + }, + }, + wantErr: true, + }, + { + name: "add another key, ok", + args: args{ + &user.AddKeyRequest{ + ExpirationDate: expirationDate, + }, + func(request *user.AddKeyRequest) error { + request.UserId = userId + _, err := Client.AddKey(IamCTX, &user.AddKeyRequest{ + ExpirationDate: expirationDate, + UserId: userId, + }) + return err + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + got, err := Client.AddKey(CTX, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.KeyId, "key id is empty") + if tt.wantEmtpyKey { + assert.Empty(t, got.KeyContent, "key content is not empty") + } else { + assert.NotEmpty(t, got.KeyContent, "key content is empty") + } + creationDate := got.CreationDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_AddKey_Permission(t *testing.T) { + OrgCTX := CTX + otherOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("AddKey-%s", gofakeit.AppName()), gofakeit.Email()) + otherOrgUser, err := Client.CreateUser(IamCTX, &user.CreateUserRequest{ + OrganizationId: otherOrg.OrganizationId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: gofakeit.Name(), + }, + }, + }) + require.NoError(t, err) + request := &user.AddKeyRequest{ + ExpirationDate: timestamppb.New(time.Now().Add(time.Hour * 24)), + UserId: otherOrgUser.GetId(), + } + type args struct { + ctx context.Context + req *user.AddKeyRequest + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "system, ok", + args: args{SystemCTX, request}, + }, + { + name: "instance, ok", + args: args{IamCTX, request}, + }, + { + name: "org, error", + args: args{OrgCTX, request}, + wantErr: true, + }, + { + name: "user, error", + args: args{UserCTX, request}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + require.NoError(t, err) + got, err := Client.AddKey(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.KeyId, "key id is empty") + assert.NotEmpty(t, got.KeyContent, "key content is empty") + creationDate := got.CreationDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_RemoveKey(t *testing.T) { + resp := Instance.CreateUserTypeMachine(IamCTX) + userId := resp.GetId() + expirationDate := timestamppb.New(time.Now().Add(time.Hour * 24)) + type args struct { + req *user.RemoveKeyRequest + prepare func(request *user.RemoveKeyRequest) error + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "remove key, user not existing", + args: args{ + &user.RemoveKeyRequest{ + UserId: "notexisting", + }, + func(request *user.RemoveKeyRequest) error { + key, err := Client.AddKey(IamCTX, &user.AddKeyRequest{ + ExpirationDate: expirationDate, + UserId: userId, + }) + request.KeyId = key.GetKeyId() + return err + }, + }, + wantErr: true, + }, + { + name: "remove key, not existing", + args: args{ + &user.RemoveKeyRequest{ + KeyId: "notexisting", + }, + func(request *user.RemoveKeyRequest) error { + request.UserId = userId + return nil + }, + }, + wantErr: true, + }, + { + name: "remove key, ok", + args: args{ + &user.RemoveKeyRequest{}, + func(request *user.RemoveKeyRequest) error { + key, err := Client.AddKey(IamCTX, &user.AddKeyRequest{ + ExpirationDate: expirationDate, + UserId: userId, + }) + request.KeyId = key.GetKeyId() + request.UserId = userId + return err + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + got, err := Client.RemoveKey(CTX, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + deletionDate := got.DeletionDate.AsTime() + assert.Greater(t, deletionDate, now, "creation date is before the test started") + assert.Less(t, deletionDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_RemoveKey_Permission(t *testing.T) { + OrgCTX := CTX + otherOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("RemoveKey-%s", gofakeit.AppName()), gofakeit.Email()) + otherOrgUser, err := Client.CreateUser(IamCTX, &user.CreateUserRequest{ + OrganizationId: otherOrg.OrganizationId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: gofakeit.Name(), + }, + }, + }) + request := &user.RemoveKeyRequest{ + UserId: otherOrgUser.GetId(), + } + prepare := func(request *user.RemoveKeyRequest) error { + key, err := Client.AddKey(IamCTX, &user.AddKeyRequest{ + ExpirationDate: timestamppb.New(time.Now().Add(time.Hour * 24)), + UserId: otherOrgUser.GetId(), + }) + request.KeyId = key.GetKeyId() + return err + } + require.NoError(t, err) + type args struct { + ctx context.Context + req *user.RemoveKeyRequest + prepare func(request *user.RemoveKeyRequest) error + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "system, ok", + args: args{SystemCTX, request, prepare}, + }, + { + name: "instance, ok", + args: args{IamCTX, request, prepare}, + }, + { + name: "org, error", + args: args{OrgCTX, request, prepare}, + wantErr: true, + }, + { + name: "user, error", + args: args{UserCTX, request, prepare}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + require.NoError(t, tt.args.prepare(tt.args.req)) + got, err := Client.RemoveKey(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.DeletionDate, "client key is empty") + creationDate := got.DeletionDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_ListKeys(t *testing.T) { + type args struct { + ctx context.Context + req *user.ListKeysRequest + } + type testCase struct { + name string + args args + want *user.ListKeysResponse + } + OrgCTX := CTX + otherOrg := Instance.CreateOrganization(SystemCTX, fmt.Sprintf("ListKeys-%s", gofakeit.AppName()), gofakeit.Email()) + otherOrgUser, err := Client.CreateUser(SystemCTX, &user.CreateUserRequest{ + OrganizationId: otherOrg.OrganizationId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: gofakeit.Name(), + }, + }, + }) + require.NoError(t, err) + otherOrgUserId := otherOrgUser.GetId() + otherUserId := Instance.CreateUserTypeMachine(SystemCTX).GetId() + onlySinceTestStartFilter := &user.KeysSearchFilter{Filter: &user.KeysSearchFilter_CreatedDateFilter{CreatedDateFilter: &filter.TimestampFilter{ + Timestamp: timestamppb.Now(), + Method: filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_AFTER_OR_EQUALS, + }}} + myOrgId := Instance.DefaultOrg.GetId() + myUserId := Instance.Users.Get(integration.UserTypeNoPermission).ID + expiresInADay := time.Now().Truncate(time.Hour).Add(time.Hour * 24) + myDataPoint := setupKeyDataPoint(t, myUserId, myOrgId, expiresInADay) + otherUserDataPoint := setupKeyDataPoint(t, otherUserId, myOrgId, expiresInADay) + otherOrgDataPointExpiringSoon := setupKeyDataPoint(t, otherOrgUserId, otherOrg.OrganizationId, time.Now().Truncate(time.Hour).Add(time.Hour)) + otherOrgDataPointExpiringLate := setupKeyDataPoint(t, otherOrgUserId, otherOrg.OrganizationId, expiresInADay.Add(time.Hour*24*30)) + sortingColumnExpirationDate := user.KeyFieldName_KEY_FIELD_NAME_KEY_EXPIRATION_DATE + awaitKeys(t, onlySinceTestStartFilter, + otherOrgDataPointExpiringSoon.GetId(), + otherOrgDataPointExpiringLate.GetId(), + otherUserDataPoint.GetId(), + myDataPoint.GetId(), + ) + tests := []testCase{ + { + name: "list all, instance", + args: args{ + IamCTX, + &user.ListKeysRequest{Filters: []*user.KeysSearchFilter{onlySinceTestStartFilter}}, + }, + want: &user.ListKeysResponse{ + Result: []*user.Key{ + otherOrgDataPointExpiringLate, + otherOrgDataPointExpiringSoon, + otherUserDataPoint, + myDataPoint, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 100, + }, + }, + }, + { + name: "list all, org", + args: args{ + OrgCTX, + &user.ListKeysRequest{Filters: []*user.KeysSearchFilter{onlySinceTestStartFilter}}, + }, + want: &user.ListKeysResponse{ + Result: []*user.Key{ + otherUserDataPoint, + myDataPoint, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + }, + }, + { + name: "list all, user", + args: args{ + UserCTX, + &user.ListKeysRequest{Filters: []*user.KeysSearchFilter{onlySinceTestStartFilter}}, + }, + want: &user.ListKeysResponse{ + Result: []*user.Key{ + myDataPoint, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + }, + }, + { + name: "list by id", + args: args{ + IamCTX, + &user.ListKeysRequest{ + Filters: []*user.KeysSearchFilter{ + onlySinceTestStartFilter, + { + Filter: &user.KeysSearchFilter_KeyIdFilter{ + KeyIdFilter: &filter.IDFilter{Id: otherOrgDataPointExpiringSoon.Id}, + }, + }, + }, + }, + }, + want: &user.ListKeysResponse{ + Result: []*user.Key{ + otherOrgDataPointExpiringSoon, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + }, + }, + { + name: "list all from other org", + args: args{ + IamCTX, + &user.ListKeysRequest{ + Filters: []*user.KeysSearchFilter{ + onlySinceTestStartFilter, + { + Filter: &user.KeysSearchFilter_OrganizationIdFilter{ + OrganizationIdFilter: &filter.IDFilter{Id: otherOrg.OrganizationId}, + }, + }, + }, + }, + }, + want: &user.ListKeysResponse{ + Result: []*user.Key{ + otherOrgDataPointExpiringLate, + otherOrgDataPointExpiringSoon, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + }, + }, + { + name: "sort by next expiration dates", + args: args{ + IamCTX, + &user.ListKeysRequest{ + Pagination: &filter.PaginationRequest{ + Asc: true, + }, + SortingColumn: &sortingColumnExpirationDate, + Filters: []*user.KeysSearchFilter{ + onlySinceTestStartFilter, + {Filter: &user.KeysSearchFilter_OrganizationIdFilter{OrganizationIdFilter: &filter.IDFilter{Id: otherOrg.OrganizationId}}}, + }, + }, + }, + want: &user.ListKeysResponse{ + Result: []*user.Key{ + otherOrgDataPointExpiringSoon, + otherOrgDataPointExpiringLate, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + }, + }, + { + name: "get page", + args: args{ + IamCTX, + &user.ListKeysRequest{ + Pagination: &filter.PaginationRequest{ + Offset: 2, + Limit: 2, + Asc: true, + }, + Filters: []*user.KeysSearchFilter{ + onlySinceTestStartFilter, + }, + }, + }, + want: &user.ListKeysResponse{ + Result: []*user.Key{ + otherOrgDataPointExpiringSoon, + otherOrgDataPointExpiringLate, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 2, + }, + }, + }, + { + name: "empty list", + args: args{ + UserCTX, + &user.ListKeysRequest{ + Filters: []*user.KeysSearchFilter{ + { + Filter: &user.KeysSearchFilter_KeyIdFilter{ + KeyIdFilter: &filter.IDFilter{Id: otherUserDataPoint.Id}, + }, + }, + }, + }, + }, + want: &user.ListKeysResponse{ + Result: []*user.Key{}, + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + }, + }, + } + t.Run("with permission flag v2", func(t *testing.T) { + setPermissionCheckV2Flag(t, true) + defer setPermissionCheckV2Flag(t, false) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.ListKeys(tt.args.ctx, tt.args.req) + require.NoError(t, err) + assert.Len(t, got.Result, len(tt.want.Result)) + if diff := cmp.Diff(tt.want, got, protocmp.Transform()); diff != "" { + t.Errorf("ListKeys() mismatch (-want +got):\n%s", diff) + } + }) + } + }) + t.Run("without permission flag v2", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.ListKeys(tt.args.ctx, tt.args.req) + require.NoError(t, err) + assert.Len(t, got.Result, len(tt.want.Result)) + // ignore the total result, as this is a known bug with the in-memory permission checks. + // The command can't know how many keys exist in the system if the SQL statement has a limit. + // This is fixed, once the in-memory permission checks are removed with https://github.com/zitadel/zitadel/issues/9188 + tt.want.Pagination.TotalResult = got.Pagination.TotalResult + if diff := cmp.Diff(tt.want, got, protocmp.Transform()); diff != "" { + t.Errorf("ListKeys() mismatch (-want +got):\n%s", diff) + } + }) + } + }) +} + +func setupKeyDataPoint(t *testing.T, userId, orgId string, expirationDate time.Time) *user.Key { + expirationDatePb := timestamppb.New(expirationDate) + newKey, err := Client.AddKey(SystemCTX, &user.AddKeyRequest{ + UserId: userId, + ExpirationDate: expirationDatePb, + PublicKey: nil, + }) + require.NoError(t, err) + return &user.Key{ + CreationDate: newKey.CreationDate, + ChangeDate: newKey.CreationDate, + Id: newKey.GetKeyId(), + UserId: userId, + OrganizationId: orgId, + ExpirationDate: expirationDatePb, + } +} + +func awaitKeys(t *testing.T, sinceTestStartFilter *user.KeysSearchFilter, keyIds ...string) { + sortingColumn := user.KeyFieldName_KEY_FIELD_NAME_ID + slices.Sort(keyIds) + require.EventuallyWithT(t, func(collect *assert.CollectT) { + result, err := Client.ListKeys(SystemCTX, &user.ListKeysRequest{ + Filters: []*user.KeysSearchFilter{sinceTestStartFilter}, + SortingColumn: &sortingColumn, + Pagination: &filter.PaginationRequest{ + Asc: true, + }, + }) + require.NoError(t, err) + if !assert.Len(collect, result.Result, len(keyIds)) { + return + } + for i := range keyIds { + keyId := keyIds[i] + require.Equal(collect, keyId, result.Result[i].GetId()) + } + }, 5*time.Second, time.Second, "key not created in time") +} diff --git a/internal/api/grpc/user/v2/integration_test/password_test.go b/internal/api/grpc/user/v2/integration_test/password_test.go index 0cd0da7454..258cdaf78d 100644 --- a/internal/api/grpc/user/v2/integration_test/password_test.go +++ b/internal/api/grpc/user/v2/integration_test/password_test.go @@ -104,7 +104,7 @@ func TestServer_RequestPasswordReset(t *testing.T) { } } -func TestServer_SetPassword(t *testing.T) { +func TestServer_Deprecated_SetPassword(t *testing.T) { type args struct { ctx context.Context req *user.SetPasswordRequest diff --git a/internal/api/grpc/user/v2/integration_test/pat_test.go b/internal/api/grpc/user/v2/integration_test/pat_test.go new file mode 100644 index 0000000000..ce974e0407 --- /dev/null +++ b/internal/api/grpc/user/v2/integration_test/pat_test.go @@ -0,0 +1,615 @@ +//go:build integration + +package user_test + +import ( + "context" + "fmt" + "slices" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/testing/protocmp" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func TestServer_AddPersonalAccessToken(t *testing.T) { + resp := Instance.CreateUserTypeMachine(IamCTX) + userId := resp.GetId() + expirationDate := timestamppb.New(time.Now().Add(time.Hour * 24)) + type args struct { + req *user.AddPersonalAccessTokenRequest + prepare func(request *user.AddPersonalAccessTokenRequest) error + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "add pat, user not existing", + args: args{ + &user.AddPersonalAccessTokenRequest{ + UserId: "notexisting", + ExpirationDate: expirationDate, + }, + func(request *user.AddPersonalAccessTokenRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "add pat, ok", + args: args{ + &user.AddPersonalAccessTokenRequest{ + ExpirationDate: expirationDate, + }, + func(request *user.AddPersonalAccessTokenRequest) error { + request.UserId = userId + return nil + }, + }, + }, + { + name: "add pat human, not ok", + args: args{ + &user.AddPersonalAccessTokenRequest{ + ExpirationDate: expirationDate, + }, + func(request *user.AddPersonalAccessTokenRequest) error { + resp := Instance.CreateUserTypeHuman(IamCTX) + request.UserId = resp.Id + return nil + }, + }, + wantErr: true, + }, + { + name: "add another pat, ok", + args: args{ + &user.AddPersonalAccessTokenRequest{ + ExpirationDate: expirationDate, + }, + func(request *user.AddPersonalAccessTokenRequest) error { + request.UserId = userId + _, err := Client.AddPersonalAccessToken(IamCTX, &user.AddPersonalAccessTokenRequest{ + ExpirationDate: expirationDate, + UserId: userId, + }) + return err + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + got, err := Client.AddPersonalAccessToken(CTX, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.TokenId, "id is empty") + assert.NotEmpty(t, got.Token, "token is empty") + creationDate := got.CreationDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_AddPersonalAccessToken_Permission(t *testing.T) { + OrgCTX := CTX + otherOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("AddPersonalAccessToken-%s", gofakeit.AppName()), gofakeit.Email()) + otherOrgUser, err := Client.CreateUser(IamCTX, &user.CreateUserRequest{ + OrganizationId: otherOrg.OrganizationId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: gofakeit.Name(), + }, + }, + }) + require.NoError(t, err) + request := &user.AddPersonalAccessTokenRequest{ + ExpirationDate: timestamppb.New(time.Now().Add(time.Hour * 24)), + UserId: otherOrgUser.GetId(), + } + type args struct { + ctx context.Context + req *user.AddPersonalAccessTokenRequest + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "system, ok", + args: args{SystemCTX, request}, + }, + { + name: "instance, ok", + args: args{IamCTX, request}, + }, + { + name: "org, error", + args: args{OrgCTX, request}, + wantErr: true, + }, + { + name: "user, error", + args: args{UserCTX, request}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + require.NoError(t, err) + got, err := Client.AddPersonalAccessToken(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.TokenId, "id is empty") + assert.NotEmpty(t, got.Token, "token is empty") + creationDate := got.CreationDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_RemovePersonalAccessToken(t *testing.T) { + resp := Instance.CreateUserTypeMachine(IamCTX) + userId := resp.GetId() + expirationDate := timestamppb.New(time.Now().Add(time.Hour * 24)) + type args struct { + req *user.RemovePersonalAccessTokenRequest + prepare func(request *user.RemovePersonalAccessTokenRequest) error + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "remove pat, user not existing", + args: args{ + &user.RemovePersonalAccessTokenRequest{ + UserId: "notexisting", + }, + func(request *user.RemovePersonalAccessTokenRequest) error { + pat, err := Client.AddPersonalAccessToken(CTX, &user.AddPersonalAccessTokenRequest{ + ExpirationDate: expirationDate, + UserId: userId, + }) + request.TokenId = pat.GetTokenId() + return err + }, + }, + wantErr: true, + }, + { + name: "remove pat, not existing", + args: args{ + &user.RemovePersonalAccessTokenRequest{ + TokenId: "notexisting", + }, + func(request *user.RemovePersonalAccessTokenRequest) error { + request.UserId = userId + return nil + }, + }, + wantErr: true, + }, + { + name: "remove pat, ok", + args: args{ + &user.RemovePersonalAccessTokenRequest{}, + func(request *user.RemovePersonalAccessTokenRequest) error { + pat, err := Client.AddPersonalAccessToken(CTX, &user.AddPersonalAccessTokenRequest{ + ExpirationDate: expirationDate, + UserId: userId, + }) + request.TokenId = pat.GetTokenId() + request.UserId = userId + return err + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + got, err := Client.RemovePersonalAccessToken(CTX, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + deletionDate := got.DeletionDate.AsTime() + assert.Greater(t, deletionDate, now, "creation date is before the test started") + assert.Less(t, deletionDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_RemovePersonalAccessToken_Permission(t *testing.T) { + otherOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("RemovePersonalAccessToken-%s", gofakeit.AppName()), gofakeit.Email()) + otherOrgUser, err := Client.CreateUser(IamCTX, &user.CreateUserRequest{ + OrganizationId: otherOrg.OrganizationId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: gofakeit.Name(), + }, + }, + }) + request := &user.RemovePersonalAccessTokenRequest{ + UserId: otherOrgUser.GetId(), + } + prepare := func(request *user.RemovePersonalAccessTokenRequest) error { + pat, err := Client.AddPersonalAccessToken(IamCTX, &user.AddPersonalAccessTokenRequest{ + ExpirationDate: timestamppb.New(time.Now().Add(time.Hour * 24)), + UserId: otherOrgUser.GetId(), + }) + request.TokenId = pat.GetTokenId() + return err + } + require.NoError(t, err) + type args struct { + ctx context.Context + req *user.RemovePersonalAccessTokenRequest + prepare func(request *user.RemovePersonalAccessTokenRequest) error + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "system, ok", + args: args{SystemCTX, request, prepare}, + }, + { + name: "instance, ok", + args: args{IamCTX, request, prepare}, + }, + { + name: "org, error", + args: args{CTX, request, prepare}, + wantErr: true, + }, + { + name: "user, error", + args: args{UserCTX, request, prepare}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + require.NoError(t, tt.args.prepare(tt.args.req)) + got, err := Client.RemovePersonalAccessToken(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.DeletionDate, "client pat is empty") + creationDate := got.DeletionDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_ListPersonalAccessTokens(t *testing.T) { + type args struct { + ctx context.Context + req *user.ListPersonalAccessTokensRequest + } + type testCase struct { + name string + args args + want *user.ListPersonalAccessTokensResponse + } + OrgCTX := CTX + otherOrg := Instance.CreateOrganization(SystemCTX, fmt.Sprintf("ListPersonalAccessTokens-%s", gofakeit.AppName()), gofakeit.Email()) + otherOrgUser, err := Client.CreateUser(SystemCTX, &user.CreateUserRequest{ + OrganizationId: otherOrg.OrganizationId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: gofakeit.Name(), + }, + }, + }) + require.NoError(t, err) + otherOrgUserId := otherOrgUser.GetId() + otherUserId := Instance.CreateUserTypeMachine(SystemCTX).GetId() + onlySinceTestStartFilter := &user.PersonalAccessTokensSearchFilter{Filter: &user.PersonalAccessTokensSearchFilter_CreatedDateFilter{CreatedDateFilter: &filter.TimestampFilter{ + Timestamp: timestamppb.Now(), + Method: filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_AFTER_OR_EQUALS, + }}} + myOrgId := Instance.DefaultOrg.GetId() + myUserId := Instance.Users.Get(integration.UserTypeNoPermission).ID + expiresInADay := time.Now().Truncate(time.Hour).Add(time.Hour * 24) + myDataPoint := setupPATDataPoint(t, myUserId, myOrgId, expiresInADay) + otherUserDataPoint := setupPATDataPoint(t, otherUserId, myOrgId, expiresInADay) + otherOrgDataPointExpiringSoon := setupPATDataPoint(t, otherOrgUserId, otherOrg.OrganizationId, time.Now().Truncate(time.Hour).Add(time.Hour)) + otherOrgDataPointExpiringLate := setupPATDataPoint(t, otherOrgUserId, otherOrg.OrganizationId, expiresInADay.Add(time.Hour*24*30)) + sortingColumnExpirationDate := user.PersonalAccessTokenFieldName_PERSONAL_ACCESS_TOKEN_FIELD_NAME_EXPIRATION_DATE + awaitPersonalAccessTokens(t, + onlySinceTestStartFilter, + otherOrgDataPointExpiringSoon.GetId(), + otherOrgDataPointExpiringLate.GetId(), + otherUserDataPoint.GetId(), + myDataPoint.GetId(), + ) + tests := []testCase{ + { + name: "list all, instance", + args: args{ + IamCTX, + &user.ListPersonalAccessTokensRequest{ + Filters: []*user.PersonalAccessTokensSearchFilter{onlySinceTestStartFilter}, + }, + }, + want: &user.ListPersonalAccessTokensResponse{ + Result: []*user.PersonalAccessToken{ + otherOrgDataPointExpiringLate, + otherOrgDataPointExpiringSoon, + otherUserDataPoint, + myDataPoint, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 100, + }, + }, + }, + { + name: "list all, org", + args: args{ + OrgCTX, + &user.ListPersonalAccessTokensRequest{ + Filters: []*user.PersonalAccessTokensSearchFilter{onlySinceTestStartFilter}, + }, + }, + want: &user.ListPersonalAccessTokensResponse{ + Result: []*user.PersonalAccessToken{ + otherUserDataPoint, + myDataPoint, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + }, + }, + { + name: "list all, user", + args: args{ + UserCTX, + &user.ListPersonalAccessTokensRequest{ + Filters: []*user.PersonalAccessTokensSearchFilter{onlySinceTestStartFilter}, + }, + }, + want: &user.ListPersonalAccessTokensResponse{ + Result: []*user.PersonalAccessToken{ + myDataPoint, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + }, + }, + { + name: "list by id", + args: args{ + IamCTX, + &user.ListPersonalAccessTokensRequest{ + Filters: []*user.PersonalAccessTokensSearchFilter{ + onlySinceTestStartFilter, + { + Filter: &user.PersonalAccessTokensSearchFilter_TokenIdFilter{ + TokenIdFilter: &filter.IDFilter{Id: otherOrgDataPointExpiringSoon.Id}, + }, + }, + }, + }, + }, + want: &user.ListPersonalAccessTokensResponse{ + Result: []*user.PersonalAccessToken{ + otherOrgDataPointExpiringSoon, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + }, + }, + { + name: "list all from other org", + args: args{ + IamCTX, + &user.ListPersonalAccessTokensRequest{ + Filters: []*user.PersonalAccessTokensSearchFilter{ + onlySinceTestStartFilter, + { + Filter: &user.PersonalAccessTokensSearchFilter_OrganizationIdFilter{ + OrganizationIdFilter: &filter.IDFilter{Id: otherOrg.OrganizationId}, + }, + }}, + }, + }, + want: &user.ListPersonalAccessTokensResponse{ + Result: []*user.PersonalAccessToken{ + otherOrgDataPointExpiringLate, + otherOrgDataPointExpiringSoon, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + }, + }, + { + name: "sort by next expiration dates", + args: args{ + IamCTX, + &user.ListPersonalAccessTokensRequest{ + Pagination: &filter.PaginationRequest{ + Asc: true, + }, + SortingColumn: &sortingColumnExpirationDate, + Filters: []*user.PersonalAccessTokensSearchFilter{ + onlySinceTestStartFilter, + {Filter: &user.PersonalAccessTokensSearchFilter_OrganizationIdFilter{OrganizationIdFilter: &filter.IDFilter{Id: otherOrg.OrganizationId}}}, + }, + }, + }, + want: &user.ListPersonalAccessTokensResponse{ + Result: []*user.PersonalAccessToken{ + otherOrgDataPointExpiringSoon, + otherOrgDataPointExpiringLate, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + }, + }, + { + name: "get page", + args: args{ + IamCTX, + &user.ListPersonalAccessTokensRequest{ + Pagination: &filter.PaginationRequest{ + Offset: 2, + Limit: 2, + Asc: true, + }, + Filters: []*user.PersonalAccessTokensSearchFilter{ + onlySinceTestStartFilter, + }, + }, + }, + want: &user.ListPersonalAccessTokensResponse{ + Result: []*user.PersonalAccessToken{ + otherOrgDataPointExpiringSoon, + otherOrgDataPointExpiringLate, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 2, + }, + }, + }, + { + name: "empty list", + args: args{ + UserCTX, + &user.ListPersonalAccessTokensRequest{ + Filters: []*user.PersonalAccessTokensSearchFilter{ + { + Filter: &user.PersonalAccessTokensSearchFilter_TokenIdFilter{ + TokenIdFilter: &filter.IDFilter{Id: otherUserDataPoint.Id}, + }, + }, + }, + }, + }, + want: &user.ListPersonalAccessTokensResponse{ + Result: []*user.PersonalAccessToken{}, + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + }, + }, + } + t.Run("with permission flag v2", func(t *testing.T) { + setPermissionCheckV2Flag(t, true) + defer setPermissionCheckV2Flag(t, false) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.ListPersonalAccessTokens(tt.args.ctx, tt.args.req) + require.NoError(t, err) + assert.Len(t, got.Result, len(tt.want.Result)) + if diff := cmp.Diff(tt.want, got, protocmp.Transform()); diff != "" { + t.Errorf("ListPersonalAccessTokens() mismatch (-want +got):\n%s", diff) + } + }) + } + }) + t.Run("without permission flag v2", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.ListPersonalAccessTokens(tt.args.ctx, tt.args.req) + require.NoError(t, err) + assert.Len(t, got.Result, len(tt.want.Result)) + // ignore the total result, as this is a known bug with the in-memory permission checks. + // The command can't know how many keys exist in the system if the SQL statement has a limit. + // This is fixed, once the in-memory permission checks are removed with https://github.com/zitadel/zitadel/issues/9188 + tt.want.Pagination.TotalResult = got.Pagination.TotalResult + if diff := cmp.Diff(tt.want, got, protocmp.Transform()); diff != "" { + t.Errorf("ListPersonalAccessTokens() mismatch (-want +got):\n%s", diff) + } + }) + } + }) +} + +func setupPATDataPoint(t *testing.T, userId, orgId string, expirationDate time.Time) *user.PersonalAccessToken { + expirationDatePb := timestamppb.New(expirationDate) + newPersonalAccessToken, err := Client.AddPersonalAccessToken(SystemCTX, &user.AddPersonalAccessTokenRequest{ + UserId: userId, + ExpirationDate: expirationDatePb, + }) + require.NoError(t, err) + return &user.PersonalAccessToken{ + CreationDate: newPersonalAccessToken.CreationDate, + ChangeDate: newPersonalAccessToken.CreationDate, + Id: newPersonalAccessToken.GetTokenId(), + UserId: userId, + OrganizationId: orgId, + ExpirationDate: expirationDatePb, + } +} + +func awaitPersonalAccessTokens(t *testing.T, sinceTestStartFilter *user.PersonalAccessTokensSearchFilter, patIds ...string) { + sortingColumn := user.PersonalAccessTokenFieldName_PERSONAL_ACCESS_TOKEN_FIELD_NAME_ID + slices.Sort(patIds) + require.EventuallyWithT(t, func(collect *assert.CollectT) { + result, err := Client.ListPersonalAccessTokens(SystemCTX, &user.ListPersonalAccessTokensRequest{ + Filters: []*user.PersonalAccessTokensSearchFilter{sinceTestStartFilter}, + SortingColumn: &sortingColumn, + Pagination: &filter.PaginationRequest{ + Asc: true, + }, + }) + require.NoError(t, err) + if !assert.Len(collect, result.Result, len(patIds)) { + return + } + for i := range patIds { + patId := patIds[i] + require.Equal(collect, patId, result.Result[i].GetId()) + } + }, 5*time.Second, time.Second, "pat not created in time") +} diff --git a/internal/api/grpc/user/v2/integration_test/phone_test.go b/internal/api/grpc/user/v2/integration_test/phone_test.go index 49050c5fe6..b87f9a9f28 100644 --- a/internal/api/grpc/user/v2/integration_test/phone_test.go +++ b/internal/api/grpc/user/v2/integration_test/phone_test.go @@ -17,7 +17,7 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func TestServer_SetPhone(t *testing.T) { +func TestServer_Deprecated_SetPhone(t *testing.T) { userID := Instance.CreateHumanUser(CTX).GetUserId() tests := []struct { @@ -249,7 +249,7 @@ func TestServer_VerifyPhone(t *testing.T) { } } -func TestServer_RemovePhone(t *testing.T) { +func TestServer_Deprecated_RemovePhone(t *testing.T) { userResp := Instance.CreateHumanUser(CTX) failResp := Instance.CreateHumanUserNoPhone(CTX) otherUser := Instance.CreateHumanUser(CTX).GetUserId() diff --git a/internal/api/grpc/user/v2/integration_test/secret_test.go b/internal/api/grpc/user/v2/integration_test/secret_test.go new file mode 100644 index 0000000000..8ff537b1fd --- /dev/null +++ b/internal/api/grpc/user/v2/integration_test/secret_test.go @@ -0,0 +1,347 @@ +//go:build integration + +package user_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func TestServer_AddSecret(t *testing.T) { + type args struct { + ctx context.Context + req *user.AddSecretRequest + prepare func(request *user.AddSecretRequest) error + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "add secret, user not existing", + args: args{ + CTX, + &user.AddSecretRequest{ + UserId: "notexisting", + }, + func(request *user.AddSecretRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "add secret, ok", + args: args{ + CTX, + &user.AddSecretRequest{}, + func(request *user.AddSecretRequest) error { + resp := Instance.CreateUserTypeMachine(CTX) + request.UserId = resp.GetId() + return nil + }, + }, + }, + { + name: "add secret human, not ok", + args: args{ + CTX, + &user.AddSecretRequest{}, + func(request *user.AddSecretRequest) error { + resp := Instance.CreateUserTypeMachine(CTX) + request.UserId = resp.GetId() + return nil + }, + }, + }, + { + name: "overwrite secret, ok", + args: args{ + CTX, + &user.AddSecretRequest{}, + func(request *user.AddSecretRequest) error { + resp := Instance.CreateUserTypeMachine(CTX) + request.UserId = resp.GetId() + _, err := Client.AddSecret(CTX, &user.AddSecretRequest{ + UserId: resp.GetId(), + }) + return err + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + got, err := Client.AddSecret(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.ClientSecret, "client secret is empty") + creationDate := got.CreationDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_AddSecret_Permission(t *testing.T) { + otherOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("AddSecret-%s", gofakeit.AppName()), gofakeit.Email()) + otherOrgUser, err := Instance.Client.UserV2.CreateUser(IamCTX, &user.CreateUserRequest{ + OrganizationId: otherOrg.OrganizationId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: gofakeit.Name(), + }, + }, + }) + require.NoError(t, err) + + type args struct { + ctx context.Context + req *user.AddSecretRequest + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "system, ok", + args: args{ + SystemCTX, + &user.AddSecretRequest{ + UserId: otherOrgUser.GetId(), + }, + }, + }, + { + name: "instance, ok", + args: args{ + IamCTX, + &user.AddSecretRequest{ + UserId: otherOrgUser.GetId(), + }, + }, + }, + { + name: "org, error", + args: args{ + CTX, + &user.AddSecretRequest{ + UserId: otherOrgUser.GetId(), + }, + }, + wantErr: true, + }, + { + name: "user, error", + args: args{ + UserCTX, + &user.AddSecretRequest{ + UserId: otherOrgUser.GetId(), + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + require.NoError(t, err) + got, err := Client.AddSecret(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.ClientSecret, "client secret is empty") + creationDate := got.CreationDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_RemoveSecret(t *testing.T) { + type args struct { + ctx context.Context + req *user.RemoveSecretRequest + prepare func(request *user.RemoveSecretRequest) error + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "remove secret, user not existing", + args: args{ + CTX, + &user.RemoveSecretRequest{ + UserId: "notexisting", + }, + func(request *user.RemoveSecretRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "remove secret, not existing", + args: args{ + CTX, + &user.RemoveSecretRequest{}, + func(request *user.RemoveSecretRequest) error { + resp := Instance.CreateUserTypeMachine(CTX) + request.UserId = resp.GetId() + return nil + }, + }, + wantErr: true, + }, + { + name: "remove secret, ok", + args: args{ + CTX, + &user.RemoveSecretRequest{}, + func(request *user.RemoveSecretRequest) error { + resp := Instance.CreateUserTypeMachine(CTX) + request.UserId = resp.GetId() + _, err := Instance.Client.UserV2.AddSecret(CTX, &user.AddSecretRequest{ + UserId: resp.GetId(), + }) + return err + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + got, err := Client.RemoveSecret(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + deletionDate := got.DeletionDate.AsTime() + assert.Greater(t, deletionDate, now, "creation date is before the test started") + assert.Less(t, deletionDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_RemoveSecret_Permission(t *testing.T) { + otherOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("RemoveSecret-%s", gofakeit.AppName()), gofakeit.Email()) + otherOrgUser, err := Instance.Client.UserV2.CreateUser(IamCTX, &user.CreateUserRequest{ + OrganizationId: otherOrg.OrganizationId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: gofakeit.Name(), + }, + }, + }) + require.NoError(t, err) + + type args struct { + ctx context.Context + req *user.RemoveSecretRequest + prepare func(request *user.RemoveSecretRequest) error + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "system, ok", + args: args{ + SystemCTX, + &user.RemoveSecretRequest{ + UserId: otherOrgUser.GetId(), + }, + func(request *user.RemoveSecretRequest) error { + _, err := Instance.Client.UserV2.AddSecret(IamCTX, &user.AddSecretRequest{ + UserId: otherOrgUser.GetId(), + }) + return err + }, + }, + }, + { + name: "instance, ok", + args: args{ + IamCTX, + &user.RemoveSecretRequest{ + UserId: otherOrgUser.GetId(), + }, + func(request *user.RemoveSecretRequest) error { + _, err := Instance.Client.UserV2.AddSecret(IamCTX, &user.AddSecretRequest{ + UserId: otherOrgUser.GetId(), + }) + return err + }, + }, + }, + { + name: "org, error", + args: args{ + CTX, + &user.RemoveSecretRequest{ + UserId: otherOrgUser.GetId(), + }, + func(request *user.RemoveSecretRequest) error { + _, err := Instance.Client.UserV2.AddSecret(IamCTX, &user.AddSecretRequest{ + UserId: otherOrgUser.GetId(), + }) + return err + }, + }, + wantErr: true, + }, + { + name: "user, error", + args: args{ + UserCTX, + &user.RemoveSecretRequest{ + UserId: otherOrgUser.GetId(), + }, + func(request *user.RemoveSecretRequest) error { + _, err := Instance.Client.UserV2.AddSecret(IamCTX, &user.AddSecretRequest{ + UserId: otherOrgUser.GetId(), + }) + return err + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + require.NoError(t, tt.args.prepare(tt.args.req)) + got, err := Client.RemoveSecret(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.DeletionDate, "client secret is empty") + creationDate := got.DeletionDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + }) + } +} diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index 4cf4ab21f8..4eee44ab44 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -57,7 +57,7 @@ func TestMain(m *testing.M) { }()) } -func TestServer_AddHumanUser(t *testing.T) { +func TestServer_Deprecated_AddHumanUser(t *testing.T) { idpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) type args struct { ctx context.Context @@ -652,6 +652,7 @@ func TestServer_AddHumanUser(t *testing.T) { t.Run(tt.name, func(t *testing.T) { userID := fmt.Sprint(time.Now().UnixNano() + int64(i)) tt.args.req.UserId = &userID + // In order to prevent unique constraint errors, we set the email to a unique value if email := tt.args.req.GetEmail(); email != nil { email.Email = fmt.Sprintf("%s@me.now", userID) } @@ -666,7 +667,6 @@ func TestServer_AddHumanUser(t *testing.T) { return } require.NoError(t, err) - assert.Equal(t, tt.want.GetUserId(), got.GetUserId()) if tt.want.GetEmailCode() != "" { assert.NotEmpty(t, got.GetEmailCode()) @@ -683,7 +683,7 @@ func TestServer_AddHumanUser(t *testing.T) { } } -func TestServer_AddHumanUser_Permission(t *testing.T) { +func TestServer_Deprecated_AddHumanUser_Permission(t *testing.T) { newOrgOwnerEmail := gofakeit.Email() newOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("AddHuman-%s", gofakeit.AppName()), newOrgOwnerEmail) type args struct { @@ -876,7 +876,7 @@ func TestServer_AddHumanUser_Permission(t *testing.T) { } } -func TestServer_UpdateHumanUser(t *testing.T) { +func TestServer_Deprecated_UpdateHumanUser(t *testing.T) { type args struct { ctx context.Context req *user.UpdateHumanUserRequest @@ -1237,7 +1237,7 @@ func TestServer_UpdateHumanUser(t *testing.T) { } } -func TestServer_UpdateHumanUser_Permission(t *testing.T) { +func TestServer_Deprecated_UpdateHumanUser_Permission(t *testing.T) { newOrgOwnerEmail := gofakeit.Email() newOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("UpdateHuman-%s", gofakeit.AppName()), newOrgOwnerEmail) newUserID := newOrg.CreatedAdmins[0].GetUserId() @@ -1834,15 +1834,26 @@ func TestServer_DeleteUser(t *testing.T) { args: args{ req: &user.DeleteUserRequest{}, prepare: func(t *testing.T, request *user.DeleteUserRequest) context.Context { - removeUser, err := Instance.Client.Mgmt.AddMachineUser(CTX, &mgmt.AddMachineUserRequest{ - UserName: gofakeit.Username(), - Name: gofakeit.Name(), + removeUser, err := Client.CreateUser(CTX, &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "givenName", + FamilyName: "familyName", + }, + Email: &user.SetHumanEmail{ + Email: gofakeit.Email(), + Verification: &user.SetHumanEmail_IsVerified{IsVerified: true}, + }, + }, + }, }) - request.UserId = removeUser.UserId require.NoError(t, err) - tokenResp, err := Instance.Client.Mgmt.AddPersonalAccessToken(CTX, &mgmt.AddPersonalAccessTokenRequest{UserId: removeUser.UserId}) - require.NoError(t, err) - return integration.WithAuthorizationToken(UserCTX, tokenResp.Token) + request.UserId = removeUser.Id + Instance.RegisterUserPasskey(CTX, removeUser.Id) + _, token, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, removeUser.Id) + return integration.WithAuthorizationToken(UserCTX, token) }, }, want: &user.DeleteUserResponse{ @@ -3610,7 +3621,6 @@ func TestServer_HumanMFAInitSkipped(t *testing.T) { t.Run(tt.name, func(t *testing.T) { err := tt.args.prepare(tt.args.req) require.NoError(t, err) - got, err := Client.HumanMFAInitSkipped(tt.args.ctx, tt.args.req) if tt.wantErr { require.Error(t, err) @@ -3624,3 +3634,1678 @@ func TestServer_HumanMFAInitSkipped(t *testing.T) { }) } } + +func TestServer_CreateUser(t *testing.T) { + type args struct { + ctx context.Context + req *user.CreateUserRequest + } + type testCase struct { + args args + want *user.CreateUserResponse + wantErr bool + } + tests := []struct { + name string + testCase func(runId string) testCase + }{ + { + name: "default verification", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + }, + wantErr: false, + } + }, + }, + { + name: "return email verification code", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_ReturnCode{ + ReturnCode: &user.ReturnEmailVerificationCode{}, + }, + }, + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + EmailCode: gu.Ptr("something"), + }, + } + }, + }, + { + name: "custom template", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_SendCode{ + SendCode: &user.SendEmailVerificationCode{ + UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"), + }, + }, + }, + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + }, + } + }, + }, + { + name: "return phone verification code", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + Phone: &user.SetHumanPhone{ + Phone: "+41791234567", + Verification: &user.SetHumanPhone_ReturnCode{ + ReturnCode: &user.ReturnPhoneVerificationCode{}, + }, + }, + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + PhoneCode: gu.Ptr("something"), + }, + } + }, + }, + { + name: "custom template error", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_SendCode{ + SendCode: &user.SendEmailVerificationCode{ + UrlTemplate: gu.Ptr("{{"), + }, + }, + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "missing REQUIRED profile", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_ReturnCode{ + ReturnCode: &user.ReturnEmailVerificationCode{}, + }, + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "missing REQUIRED email", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "missing empty email", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{}, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "missing idp", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_IsVerified{ + IsVerified: true, + }, + }, + IdpLinks: []*user.IDPLink{ + { + IdpId: "idpID", + UserId: "userID", + UserName: "username", + }, + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "with idp", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + idpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_IsVerified{ + IsVerified: true, + }, + }, + IdpLinks: []*user.IDPLink{ + { + IdpId: idpResp.Id, + UserId: "userID", + UserName: "username", + }, + }, + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + }, + } + }, + }, + { + name: "with totp", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_IsVerified{ + IsVerified: true, + }, + }, + TotpSecret: gu.Ptr("secret"), + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + }, + } + }, + }, + { + name: "password not complexity conform", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + PasswordType: &user.CreateUserRequest_Human_Password{ + Password: &user.Password{ + Password: "insufficient", + }, + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "hashed password", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + PasswordType: &user.CreateUserRequest_Human_HashedPassword{ + HashedPassword: &user.HashedPassword{ + Hash: "$2y$12$hXUrnqdq1RIIYZ2HPytIIe5lXdIvbhqrTvdPsSF7o.jFh817Z6lwm", + }, + }, + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + }, + } + }, + }, + { + name: "unsupported hashed password", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + PasswordType: &user.CreateUserRequest_Human_HashedPassword{ + HashedPassword: &user.HashedPassword{ + Hash: "$scrypt$ln=16,r=8,p=1$cmFuZG9tc2FsdGlzaGFyZA$Rh+NnJNo1I6nRwaNqbDm6kmADswD1+7FTKZ7Ln9D8nQ", + }, + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "human default username", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + }, + } + }, + }, + { + name: "machine user", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + }, + } + }, + }, + { + name: "machine default username to generated id", + testCase: func(runId string) testCase { + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + }, + } + }, + }, + { + name: "machine default username to given id", + testCase: func(runId string) testCase { + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + UserId: &runId, + OrganizationId: Instance.DefaultOrg.Id, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: runId, + }, + } + }, + }, + { + name: "org does not exist human, error", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: "does not exist", + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "org does not exist machine, error", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: "does not exist", + Username: &username, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: gofakeit.Name(), + }, + }, + }, + }, + wantErr: true, + } + }, + }, + } + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + runId := fmt.Sprint(now.UnixNano() + int64(i)) + test := tt.testCase(runId) + got, err := Client.CreateUser(test.args.ctx, test.args.req) + if test.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + creationDate := got.CreationDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + if test.want.GetEmailCode() != "" { + assert.NotEmpty(t, got.GetEmailCode(), "email code is empty") + } else { + assert.Empty(t, got.GetEmailCode(), "email code is not empty") + } + if test.want.GetPhoneCode() != "" { + assert.NotEmpty(t, got.GetPhoneCode(), "phone code is empty") + } else { + assert.Empty(t, got.GetPhoneCode(), "phone code is not empty") + } + if test.want.GetId() == "is generated" { + assert.Len(t, got.GetId(), 18, "ID is not 18 characters") + } else { + assert.Equal(t, test.want.GetId(), got.GetId(), "ID is not the same") + } + }) + } +} + +func TestServer_CreateUser_And_Compare(t *testing.T) { + type args struct { + ctx context.Context + req *user.CreateUserRequest + } + type testCase struct { + name string + args args + assert func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) + } + tests := []struct { + name string + testCase func(runId string) testCase + }{{ + name: "human given username", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + ctx: CTX, + req: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + }, + }, + }, + }, + assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) { + assert.Equal(t, username, getResponse.GetUser().GetUsername()) + }, + } + }, + }, { + name: "human username default to email", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + ctx: CTX, + req: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + }, + }, + }, + }, + assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) { + assert.Equal(t, email, getResponse.GetUser().GetUsername()) + }, + } + }, + }, { + name: "machine username given", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + return testCase{ + args: args{ + ctx: CTX, + req: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) { + assert.Equal(t, username, getResponse.GetUser().GetUsername()) + }, + } + }, + }, { + name: "machine username default to generated id", + testCase: func(runId string) testCase { + return testCase{ + args: args{ + ctx: CTX, + req: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + assert: func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) { + assert.Equal(t, createResponse.GetId(), getResponse.GetUser().GetUsername()) + }, + } + }, + }, { + name: "machine username default to given id", + testCase: func(runId string) testCase { + return testCase{ + args: args{ + ctx: CTX, + req: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserId: &runId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + assert: func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) { + assert.Equal(t, runId, getResponse.GetUser().GetUsername()) + }, + } + }, + }} + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + runId := fmt.Sprint(now.UnixNano() + int64(i)) + test := tt.testCase(runId) + createResponse, err := Client.CreateUser(test.args.ctx, test.args.req) + require.NoError(t, err) + Instance.TriggerUserByID(test.args.ctx, createResponse.GetId()) + getResponse, err := Client.GetUserByID(test.args.ctx, &user.GetUserByIDRequest{ + UserId: createResponse.GetId(), + }) + require.NoError(t, err) + test.assert(t, createResponse, getResponse) + }) + } +} + +func TestServer_CreateUser_Permission(t *testing.T) { + newOrgOwnerEmail := gofakeit.Email() + newOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("AddHuman-%s", gofakeit.AppName()), newOrgOwnerEmail) + type args struct { + ctx context.Context + req *user.CreateUserRequest + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "human system, ok", + args: args{ + SystemCTX, + &user.CreateUserRequest{ + OrganizationId: newOrg.GetOrganizationId(), + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: "this is overwritten with a unique address", + }, + }, + }, + }, + }, + }, + { + name: "human instance, ok", + args: args{ + IamCTX, + &user.CreateUserRequest{ + OrganizationId: newOrg.GetOrganizationId(), + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: "this is overwritten with a unique address", + }, + }, + }, + }, + }, + }, + { + name: "human org, error", + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: newOrg.GetOrganizationId(), + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: "this is overwritten with a unique address", + }, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "human user, error", + args: args{ + UserCTX, + &user.CreateUserRequest{ + OrganizationId: newOrg.GetOrganizationId(), + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: "this is overwritten with a unique address", + }, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "machine system, ok", + args: args{ + SystemCTX, + &user.CreateUserRequest{ + OrganizationId: newOrg.GetOrganizationId(), + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + }, + { + name: "machine instance, ok", + args: args{ + IamCTX, + &user.CreateUserRequest{ + OrganizationId: newOrg.GetOrganizationId(), + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + }, + { + name: "machine org, error", + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: newOrg.GetOrganizationId(), + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + wantErr: true, + }, + { + name: "machine user, error", + args: args{ + UserCTX, + &user.CreateUserRequest{ + OrganizationId: newOrg.GetOrganizationId(), + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + wantErr: true, + }, + } + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + userID := fmt.Sprint(time.Now().UnixNano() + int64(i)) + tt.args.req.UserId = &userID + if email := tt.args.req.GetHuman().GetEmail(); email != nil { + email.Email = fmt.Sprintf("%s@example.com", userID) + } + _, err := Client.CreateUser(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + +func TestServer_UpdateUserTypeHuman(t *testing.T) { + type args struct { + ctx context.Context + req *user.UpdateUserRequest + } + type testCase struct { + args args + want *user.UpdateUserResponse + wantErr bool + } + tests := []struct { + name string + testCase func(runId, userId string) testCase + }{ + { + name: "default verification", + testCase: func(runId, userId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Email: &user.SetHumanEmail{ + Email: email, + }, + }, + }, + }, + }, + want: &user.UpdateUserResponse{}, + wantErr: false, + } + }, + }, + { + name: "return email verification code", + testCase: func(runId, userId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_ReturnCode{ + ReturnCode: &user.ReturnEmailVerificationCode{}, + }, + }, + }, + }, + }, + }, + want: &user.UpdateUserResponse{ + EmailCode: gu.Ptr("something"), + }, + } + }, + }, + { + name: "custom template", + testCase: func(runId, userId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_SendCode{ + SendCode: &user.SendEmailVerificationCode{ + UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"), + }, + }, + }, + }, + }, + }, + }, + want: &user.UpdateUserResponse{}, + } + }, + }, + { + name: "return phone verification code", + testCase: func(runId, userId string) testCase { + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Phone: &user.SetHumanPhone{ + Phone: "+41791234568", + Verification: &user.SetHumanPhone_ReturnCode{ + ReturnCode: &user.ReturnPhoneVerificationCode{}, + }, + }, + }, + }, + }, + }, + want: &user.UpdateUserResponse{ + PhoneCode: gu.Ptr("something"), + }, + } + }, + }, + { + name: "custom template error", + testCase: func(runId, userId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_SendCode{ + SendCode: &user.SendEmailVerificationCode{ + UrlTemplate: gu.Ptr("{{"), + }, + }, + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "missing empty email", + testCase: func(runId, userId string) testCase { + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Email: &user.SetHumanEmail{}, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "password not complexity conform", + testCase: func(runId, userId string) testCase { + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Password: &user.SetPassword{ + PasswordType: &user.SetPassword_Password{ + Password: &user.Password{ + Password: "insufficient", + }, + }, + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "hashed password", + testCase: func(runId, userId string) testCase { + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Password: &user.SetPassword{ + PasswordType: &user.SetPassword_HashedPassword{ + HashedPassword: &user.HashedPassword{ + Hash: "$2y$12$hXUrnqdq1RIIYZ2HPytIIe5lXdIvbhqrTvdPsSF7o.jFh817Z6lwm", + }, + }, + }, + }, + }, + }, + }, + want: &user.UpdateUserResponse{}, + } + }, + }, + { + name: "unsupported hashed password", + testCase: func(runId, userId string) testCase { + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Password: &user.SetPassword{ + PasswordType: &user.SetPassword_HashedPassword{ + HashedPassword: &user.HashedPassword{ + Hash: "$scrypt$ln=16,r=8,p=1$cmFuZG9tc2FsdGlzaGFyZA$Rh+NnJNo1I6nRwaNqbDm6kmADswD1+7FTKZ7Ln9D8nQ", + }, + }, + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "update human user with machine fields, error", + testCase: func(runId, userId string) testCase { + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Machine_{ + Machine: &user.UpdateUserRequest_Machine{ + Name: &runId, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + } + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + runId := fmt.Sprint(now.UnixNano() + int64(i)) + userId := Instance.CreateUserTypeHuman(CTX).GetId() + test := tt.testCase(runId, userId) + got, err := Client.UpdateUser(test.args.ctx, test.args.req) + if test.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + changeDate := got.ChangeDate.AsTime() + assert.Greater(t, changeDate, now, "change date is before the test started") + assert.Less(t, changeDate, time.Now(), "change date is in the future") + if test.want.GetEmailCode() != "" { + assert.NotEmpty(t, got.GetEmailCode(), "email code is empty") + } else { + assert.Empty(t, got.GetEmailCode(), "email code is not empty") + } + if test.want.GetPhoneCode() != "" { + assert.NotEmpty(t, got.GetPhoneCode(), "phone code is empty") + } else { + assert.Empty(t, got.GetPhoneCode(), "phone code is not empty") + } + }) + } +} + +func TestServer_UpdateUserTypeMachine(t *testing.T) { + type args struct { + ctx context.Context + req *user.UpdateUserRequest + } + type testCase struct { + args args + wantErr bool + } + tests := []struct { + name string + testCase func(runId, userId string) testCase + }{ + { + name: "update machine, ok", + testCase: func(runId, userId string) testCase { + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Machine_{ + Machine: &user.UpdateUserRequest_Machine{ + Name: gu.Ptr("donald"), + }, + }, + }, + }, + } + }, + }, + { + name: "update machine user with human fields, error", + testCase: func(runId, userId string) testCase { + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Profile: &user.UpdateUserRequest_Human_Profile{ + GivenName: gu.Ptr("Donald"), + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + } + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + runId := fmt.Sprint(now.UnixNano() + int64(i)) + userId := Instance.CreateUserTypeMachine(CTX).GetId() + test := tt.testCase(runId, userId) + got, err := Client.UpdateUser(test.args.ctx, test.args.req) + if test.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + changeDate := got.ChangeDate.AsTime() + assert.Greater(t, changeDate, now, "change date is before the test started") + assert.Less(t, changeDate, time.Now(), "change date is in the future") + }) + } +} + +func TestServer_UpdateUser_And_Compare(t *testing.T) { + type args struct { + ctx context.Context + create *user.CreateUserRequest + update *user.UpdateUserRequest + } + type testCase struct { + args args + assert func(t *testing.T, getResponse *user.GetUserByIDResponse) + } + tests := []struct { + name string + testCase func(runId string) testCase + }{{ + name: "human remove phone", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + ctx: CTX, + create: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserId: &runId, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + Phone: &user.SetHumanPhone{ + Phone: "+1234567890", + }, + }, + }, + }, + update: &user.UpdateUserRequest{ + UserId: runId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Phone: &user.SetHumanPhone{}, + }, + }, + }, + }, + assert: func(t *testing.T, getResponse *user.GetUserByIDResponse) { + assert.Empty(t, getResponse.GetUser().GetHuman().GetPhone().GetPhone(), "phone is not empty") + }, + } + }, + }, { + name: "human username", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + ctx: CTX, + create: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserId: &runId, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + }, + }, + }, + update: &user.UpdateUserRequest{ + UserId: runId, + Username: &username, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{}, + }, + }, + }, + assert: func(t *testing.T, getResponse *user.GetUserByIDResponse) { + assert.Equal(t, username, getResponse.GetUser().GetUsername()) + }, + } + }, + }, { + name: "machine username", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + return testCase{ + args: args{ + ctx: CTX, + create: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserId: &runId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "Donald", + }, + }, + }, + update: &user.UpdateUserRequest{ + UserId: runId, + Username: &username, + UserType: &user.UpdateUserRequest_Machine_{ + Machine: &user.UpdateUserRequest_Machine{}, + }, + }, + }, + assert: func(t *testing.T, getResponse *user.GetUserByIDResponse) { + assert.Equal(t, username, getResponse.GetUser().GetUsername()) + }, + } + }, + }} + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + runId := fmt.Sprint(now.UnixNano() + int64(i)) + test := tt.testCase(runId) + createResponse, err := Client.CreateUser(test.args.ctx, test.args.create) + require.NoError(t, err) + _, err = Client.UpdateUser(test.args.ctx, test.args.update) + require.NoError(t, err) + Instance.TriggerUserByID(test.args.ctx, createResponse.GetId()) + getResponse, err := Client.GetUserByID(test.args.ctx, &user.GetUserByIDRequest{ + UserId: createResponse.GetId(), + }) + require.NoError(t, err) + test.assert(t, getResponse) + }) + } +} + +func TestServer_UpdateUser_Permission(t *testing.T) { + newOrgOwnerEmail := gofakeit.Email() + newOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("AddHuman-%s", gofakeit.AppName()), newOrgOwnerEmail) + newHumanUserID := newOrg.CreatedAdmins[0].GetUserId() + machineUserResp, err := Instance.Client.UserV2.CreateUser(IamCTX, &user.CreateUserRequest{ + OrganizationId: newOrg.OrganizationId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "Donald", + }, + }, + }) + require.NoError(t, err) + newMachineUserID := machineUserResp.GetId() + Instance.TriggerUserByID(IamCTX, newMachineUserID) + type args struct { + ctx context.Context + req *user.UpdateUserRequest + } + type testCase struct { + args args + wantErr bool + } + tests := []struct { + name string + testCase func() testCase + }{ + { + name: "human, system, ok", + testCase: func() testCase { + return testCase{ + args: args{ + SystemCTX, + &user.UpdateUserRequest{ + UserId: newHumanUserID, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Profile: &user.UpdateUserRequest_Human_Profile{ + GivenName: gu.Ptr("Donald"), + }, + }, + }, + }, + }, + } + }, + }, + { + name: "human instance, ok", + testCase: func() testCase { + return testCase{ + args: args{ + IamCTX, + &user.UpdateUserRequest{ + UserId: newHumanUserID, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Profile: &user.UpdateUserRequest_Human_Profile{ + GivenName: gu.Ptr("Donald"), + }, + }, + }, + }, + }, + } + }, + }, + { + name: "human org, error", + testCase: func() testCase { + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: newHumanUserID, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Profile: &user.UpdateUserRequest_Human_Profile{ + GivenName: gu.Ptr("Donald"), + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "human user, error", + testCase: func() testCase { + return testCase{ + args: args{ + UserCTX, + &user.UpdateUserRequest{ + UserId: newHumanUserID, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Profile: &user.UpdateUserRequest_Human_Profile{ + GivenName: gu.Ptr("Donald"), + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "machine system, ok", + testCase: func() testCase { + return testCase{ + args: args{ + SystemCTX, + &user.UpdateUserRequest{ + UserId: newMachineUserID, + UserType: &user.UpdateUserRequest_Machine_{ + Machine: &user.UpdateUserRequest_Machine{ + Name: gu.Ptr("Donald"), + }, + }, + }, + }, + } + }, + }, + { + name: "machine instance, ok", + testCase: func() testCase { + return testCase{ + args: args{ + IamCTX, + &user.UpdateUserRequest{ + UserId: newMachineUserID, + UserType: &user.UpdateUserRequest_Machine_{ + Machine: &user.UpdateUserRequest_Machine{ + Name: gu.Ptr("Donald"), + }, + }, + }, + }, + } + }, + }, + { + name: "machine org, error", + testCase: func() testCase { + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: newMachineUserID, + UserType: &user.UpdateUserRequest_Machine_{ + Machine: &user.UpdateUserRequest_Machine{ + Name: gu.Ptr("Donald"), + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "machine user, error", + testCase: func() testCase { + return testCase{ + args: args{ + UserCTX, + &user.UpdateUserRequest{ + UserId: newMachineUserID, + UserType: &user.UpdateUserRequest_Machine_{ + Machine: &user.UpdateUserRequest_Machine{ + Name: gu.Ptr("Donald"), + }, + }, + }, + }, + wantErr: true, + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + test := tt.testCase() + _, err := Client.UpdateUser(test.args.ctx, test.args.req) + if test.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} diff --git a/internal/api/grpc/user/v2/key.go b/internal/api/grpc/user/v2/key.go new file mode 100644 index 0000000000..59dab44248 --- /dev/null +++ b/internal/api/grpc/user/v2/key.go @@ -0,0 +1,62 @@ +package user + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func (s *Server) AddKey(ctx context.Context, req *user.AddKeyRequest) (*user.AddKeyResponse, error) { + newMachineKey := &command.MachineKey{ + ObjectRoot: models.ObjectRoot{ + AggregateID: req.UserId, + }, + ExpirationDate: req.GetExpirationDate().AsTime(), + Type: domain.AuthNKeyTypeJSON, + PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), + } + newMachineKey.PublicKey = req.PublicKey + + pubkeySupplied := len(newMachineKey.PublicKey) > 0 + details, err := s.command.AddUserMachineKey(ctx, newMachineKey) + if err != nil { + return nil, err + } + // Return key details only if the pubkey wasn't supplied, otherwise the user already has + // private key locally + var keyDetails []byte + if !pubkeySupplied { + var err error + keyDetails, err = newMachineKey.Detail() + if err != nil { + return nil, err + } + } + return &user.AddKeyResponse{ + KeyId: newMachineKey.KeyID, + KeyContent: keyDetails, + CreationDate: timestamppb.New(details.EventDate), + }, nil +} + +func (s *Server) RemoveKey(ctx context.Context, req *user.RemoveKeyRequest) (*user.RemoveKeyResponse, error) { + machineKey := &command.MachineKey{ + ObjectRoot: models.ObjectRoot{ + AggregateID: req.UserId, + }, + PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), + KeyID: req.KeyId, + } + objectDetails, err := s.command.RemoveUserMachineKey(ctx, machineKey) + if err != nil { + return nil, err + } + return &user.RemoveKeyResponse{ + DeletionDate: timestamppb.New(objectDetails.EventDate), + }, nil +} diff --git a/internal/api/grpc/user/v2/key_query.go b/internal/api/grpc/user/v2/key_query.go new file mode 100644 index 0000000000..da4f47decf --- /dev/null +++ b/internal/api/grpc/user/v2/key_query.go @@ -0,0 +1,124 @@ +package user + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + filter_pb "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func (s *Server) ListKeys(ctx context.Context, req *user.ListKeysRequest) (*user.ListKeysResponse, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) + if err != nil { + return nil, err + } + + filters, err := keyFiltersToQueries(req.Filters) + if err != nil { + return nil, err + } + search := &query.AuthNKeySearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: authnKeyFieldNameToSortingColumn(req.SortingColumn), + }, + Queries: filters, + } + result, err := s.query.SearchAuthNKeys(ctx, search, query.JoinFilterUserMachine, s.checkPermission) + if err != nil { + return nil, err + } + resp := &user.ListKeysResponse{ + Result: make([]*user.Key, len(result.AuthNKeys)), + Pagination: filter.QueryToPaginationPb(search.SearchRequest, result.SearchResponse), + } + for i, key := range result.AuthNKeys { + resp.Result[i] = &user.Key{ + CreationDate: timestamppb.New(key.CreationDate), + ChangeDate: timestamppb.New(key.ChangeDate), + Id: key.ID, + UserId: key.AggregateID, + OrganizationId: key.ResourceOwner, + ExpirationDate: timestamppb.New(key.Expiration), + } + } + return resp, nil +} + +func keyFiltersToQueries(filters []*user.KeysSearchFilter) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(filters)) + for i, filter := range filters { + q[i], err = keyFilterToQuery(filter) + if err != nil { + return nil, err + } + } + return q, nil +} + +func keyFilterToQuery(filter *user.KeysSearchFilter) (query.SearchQuery, error) { + switch q := filter.Filter.(type) { + case *user.KeysSearchFilter_CreatedDateFilter: + return authnKeyCreatedFilterToQuery(q.CreatedDateFilter) + case *user.KeysSearchFilter_ExpirationDateFilter: + return authnKeyExpirationFilterToQuery(q.ExpirationDateFilter) + case *user.KeysSearchFilter_KeyIdFilter: + return authnKeyIdFilterToQuery(q.KeyIdFilter) + case *user.KeysSearchFilter_UserIdFilter: + return authnKeyUserIdFilterToQuery(q.UserIdFilter) + case *user.KeysSearchFilter_OrganizationIdFilter: + return authnKeyOrgIdFilterToQuery(q.OrganizationIdFilter) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-vR9nC", "List.Query.Invalid") + } +} + +func authnKeyIdFilterToQuery(f *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewAuthNKeyIDQuery(f.Id) +} + +func authnKeyUserIdFilterToQuery(f *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewAuthNKeyIdentifyerQuery(f.Id) +} + +func authnKeyOrgIdFilterToQuery(f *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewAuthNKeyResourceOwnerQuery(f.Id) +} + +func authnKeyCreatedFilterToQuery(f *filter_pb.TimestampFilter) (query.SearchQuery, error) { + return query.NewAuthNKeyCreationDateQuery(f.Timestamp.AsTime(), filter.TimestampMethodPbToQuery(f.Method)) +} + +func authnKeyExpirationFilterToQuery(f *filter_pb.TimestampFilter) (query.SearchQuery, error) { + return query.NewAuthNKeyExpirationDateDateQuery(f.Timestamp.AsTime(), filter.TimestampMethodPbToQuery(f.Method)) +} + +// authnKeyFieldNameToSortingColumn defaults to the creation date because this ensures deterministic pagination +func authnKeyFieldNameToSortingColumn(field *user.KeyFieldName) query.Column { + if field == nil { + return query.AuthNKeyColumnCreationDate + } + switch *field { + case user.KeyFieldName_KEY_FIELD_NAME_UNSPECIFIED: + return query.AuthNKeyColumnCreationDate + case user.KeyFieldName_KEY_FIELD_NAME_ID: + return query.AuthNKeyColumnID + case user.KeyFieldName_KEY_FIELD_NAME_USER_ID: + return query.AuthNKeyColumnIdentifier + case user.KeyFieldName_KEY_FIELD_NAME_ORGANIZATION_ID: + return query.AuthNKeyColumnResourceOwner + case user.KeyFieldName_KEY_FIELD_NAME_CREATED_DATE: + return query.AuthNKeyColumnCreationDate + case user.KeyFieldName_KEY_FIELD_NAME_KEY_EXPIRATION_DATE: + return query.AuthNKeyColumnExpiration + default: + return query.AuthNKeyColumnCreationDate + } +} diff --git a/internal/api/grpc/user/v2/machine.go b/internal/api/grpc/user/v2/machine.go new file mode 100644 index 0000000000..010ba75678 --- /dev/null +++ b/internal/api/grpc/user/v2/machine.go @@ -0,0 +1,58 @@ +package user + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func (s *Server) createUserTypeMachine(ctx context.Context, machinePb *user.CreateUserRequest_Machine, orgId, userName, userId string) (*user.CreateUserResponse, error) { + cmd := &command.Machine{ + Username: userName, + Name: machinePb.Name, + Description: machinePb.GetDescription(), + AccessTokenType: domain.OIDCTokenTypeBearer, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: orgId, + AggregateID: userId, + }, + } + details, err := s.command.AddMachine( + ctx, + cmd, + s.command.NewPermissionCheckUserWrite(ctx), + command.AddMachineWithUsernameToIDFallback(), + ) + if err != nil { + return nil, err + } + return &user.CreateUserResponse{ + Id: cmd.AggregateID, + CreationDate: timestamppb.New(details.EventDate), + }, nil +} + +func (s *Server) updateUserTypeMachine(ctx context.Context, machinePb *user.UpdateUserRequest_Machine, userId string, userName *string) (*user.UpdateUserResponse, error) { + cmd := updateMachineUserToCommand(userId, userName, machinePb) + err := s.command.ChangeUserMachine(ctx, cmd) + if err != nil { + return nil, err + } + return &user.UpdateUserResponse{ + ChangeDate: timestamppb.New(cmd.Details.EventDate), + }, nil +} + +func updateMachineUserToCommand(userId string, userName *string, machine *user.UpdateUserRequest_Machine) *command.ChangeMachine { + return &command.ChangeMachine{ + ID: userId, + Username: userName, + Name: machine.Name, + Description: machine.Description, + } +} diff --git a/internal/api/grpc/user/v2/machine_test.go b/internal/api/grpc/user/v2/machine_test.go new file mode 100644 index 0000000000..96d77d8fa2 --- /dev/null +++ b/internal/api/grpc/user/v2/machine_test.go @@ -0,0 +1,62 @@ +package user + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/muhlemmer/gu" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func Test_patchMachineUserToCommand(t *testing.T) { + type args struct { + userId string + userName *string + machine *user.UpdateUserRequest_Machine + } + tests := []struct { + name string + args args + want *command.ChangeMachine + }{{ + name: "single property", + args: args{ + userId: "userId", + machine: &user.UpdateUserRequest_Machine{ + Name: gu.Ptr("name"), + }, + }, + want: &command.ChangeMachine{ + ID: "userId", + Name: gu.Ptr("name"), + }, + }, { + name: "all properties", + args: args{ + userId: "userId", + userName: gu.Ptr("userName"), + machine: &user.UpdateUserRequest_Machine{ + Name: gu.Ptr("name"), + Description: gu.Ptr("description"), + }, + }, + want: &command.ChangeMachine{ + ID: "userId", + Username: gu.Ptr("userName"), + Name: gu.Ptr("name"), + Description: gu.Ptr("description"), + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := updateMachineUserToCommand(tt.args.userId, tt.args.userName, tt.args.machine) + if diff := cmp.Diff(tt.want, got, cmpopts.EquateComparable(language.Tag{})); diff != "" { + t.Errorf("patchMachineUserToCommand() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/api/grpc/user/v2/pat.go b/internal/api/grpc/user/v2/pat.go new file mode 100644 index 0000000000..54f6e99367 --- /dev/null +++ b/internal/api/grpc/user/v2/pat.go @@ -0,0 +1,56 @@ +package user + +import ( + "context" + + "github.com/zitadel/oidc/v3/pkg/oidc" + "google.golang.org/protobuf/types/known/timestamppb" + + z_oidc "github.com/zitadel/zitadel/internal/api/oidc" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func (s *Server) AddPersonalAccessToken(ctx context.Context, req *user.AddPersonalAccessTokenRequest) (*user.AddPersonalAccessTokenResponse, error) { + newPat := &command.PersonalAccessToken{ + ObjectRoot: models.ObjectRoot{ + AggregateID: req.UserId, + }, + PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), + ExpirationDate: req.ExpirationDate.AsTime(), + Scopes: []string{ + oidc.ScopeOpenID, + oidc.ScopeProfile, + z_oidc.ScopeUserMetaData, + z_oidc.ScopeResourceOwner, + }, + AllowedUserType: domain.UserTypeMachine, + } + details, err := s.command.AddPersonalAccessToken(ctx, newPat) + if err != nil { + return nil, err + } + return &user.AddPersonalAccessTokenResponse{ + CreationDate: timestamppb.New(details.EventDate), + TokenId: newPat.TokenID, + Token: newPat.Token, + }, nil +} + +func (s *Server) RemovePersonalAccessToken(ctx context.Context, req *user.RemovePersonalAccessTokenRequest) (*user.RemovePersonalAccessTokenResponse, error) { + objectDetails, err := s.command.RemovePersonalAccessToken(ctx, &command.PersonalAccessToken{ + TokenID: req.TokenId, + ObjectRoot: models.ObjectRoot{ + AggregateID: req.UserId, + }, + PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), + }) + if err != nil { + return nil, err + } + return &user.RemovePersonalAccessTokenResponse{ + DeletionDate: timestamppb.New(objectDetails.EventDate), + }, nil +} diff --git a/internal/api/grpc/user/v2/pat_query.go b/internal/api/grpc/user/v2/pat_query.go new file mode 100644 index 0000000000..6bbd44d511 --- /dev/null +++ b/internal/api/grpc/user/v2/pat_query.go @@ -0,0 +1,123 @@ +package user + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + filter_pb "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func (s *Server) ListPersonalAccessTokens(ctx context.Context, req *user.ListPersonalAccessTokensRequest) (*user.ListPersonalAccessTokensResponse, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) + if err != nil { + return nil, err + } + filters, err := patFiltersToQueries(req.Filters) + if err != nil { + return nil, err + } + search := &query.PersonalAccessTokenSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: authnPersonalAccessTokenFieldNameToSortingColumn(req.SortingColumn), + }, + Queries: filters, + } + result, err := s.query.SearchPersonalAccessTokens(ctx, search, s.checkPermission) + if err != nil { + return nil, err + } + resp := &user.ListPersonalAccessTokensResponse{ + Result: make([]*user.PersonalAccessToken, len(result.PersonalAccessTokens)), + Pagination: filter.QueryToPaginationPb(search.SearchRequest, result.SearchResponse), + } + for i, pat := range result.PersonalAccessTokens { + resp.Result[i] = &user.PersonalAccessToken{ + CreationDate: timestamppb.New(pat.CreationDate), + ChangeDate: timestamppb.New(pat.ChangeDate), + Id: pat.ID, + UserId: pat.UserID, + OrganizationId: pat.ResourceOwner, + ExpirationDate: timestamppb.New(pat.Expiration), + } + } + return resp, nil +} + +func patFiltersToQueries(filters []*user.PersonalAccessTokensSearchFilter) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(filters)) + for i, filter := range filters { + q[i], err = patFilterToQuery(filter) + if err != nil { + return nil, err + } + } + return q, nil +} + +func patFilterToQuery(filter *user.PersonalAccessTokensSearchFilter) (query.SearchQuery, error) { + switch q := filter.Filter.(type) { + case *user.PersonalAccessTokensSearchFilter_CreatedDateFilter: + return authnPersonalAccessTokenCreatedFilterToQuery(q.CreatedDateFilter) + case *user.PersonalAccessTokensSearchFilter_ExpirationDateFilter: + return authnPersonalAccessTokenExpirationFilterToQuery(q.ExpirationDateFilter) + case *user.PersonalAccessTokensSearchFilter_TokenIdFilter: + return authnPersonalAccessTokenIdFilterToQuery(q.TokenIdFilter) + case *user.PersonalAccessTokensSearchFilter_UserIdFilter: + return authnPersonalAccessTokenUserIdFilterToQuery(q.UserIdFilter) + case *user.PersonalAccessTokensSearchFilter_OrganizationIdFilter: + return authnPersonalAccessTokenOrgIdFilterToQuery(q.OrganizationIdFilter) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-vR9nC", "List.Query.Invalid") + } +} + +func authnPersonalAccessTokenIdFilterToQuery(f *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewPersonalAccessTokenIDQuery(f.Id) +} + +func authnPersonalAccessTokenUserIdFilterToQuery(f *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewPersonalAccessTokenUserIDSearchQuery(f.Id) +} + +func authnPersonalAccessTokenOrgIdFilterToQuery(f *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewPersonalAccessTokenResourceOwnerSearchQuery(f.Id) +} + +func authnPersonalAccessTokenCreatedFilterToQuery(f *filter_pb.TimestampFilter) (query.SearchQuery, error) { + return query.NewPersonalAccessTokenCreationDateQuery(f.Timestamp.AsTime(), filter.TimestampMethodPbToQuery(f.Method)) +} + +func authnPersonalAccessTokenExpirationFilterToQuery(f *filter_pb.TimestampFilter) (query.SearchQuery, error) { + return query.NewPersonalAccessTokenExpirationDateDateQuery(f.Timestamp.AsTime(), filter.TimestampMethodPbToQuery(f.Method)) +} + +// authnPersonalAccessTokenFieldNameToSortingColumn defaults to the creation date because this ensures deterministic pagination +func authnPersonalAccessTokenFieldNameToSortingColumn(field *user.PersonalAccessTokenFieldName) query.Column { + if field == nil { + return query.PersonalAccessTokenColumnCreationDate + } + switch *field { + case user.PersonalAccessTokenFieldName_PERSONAL_ACCESS_TOKEN_FIELD_NAME_UNSPECIFIED: + return query.PersonalAccessTokenColumnCreationDate + case user.PersonalAccessTokenFieldName_PERSONAL_ACCESS_TOKEN_FIELD_NAME_ID: + return query.PersonalAccessTokenColumnID + case user.PersonalAccessTokenFieldName_PERSONAL_ACCESS_TOKEN_FIELD_NAME_USER_ID: + return query.PersonalAccessTokenColumnUserID + case user.PersonalAccessTokenFieldName_PERSONAL_ACCESS_TOKEN_FIELD_NAME_ORGANIZATION_ID: + return query.PersonalAccessTokenColumnResourceOwner + case user.PersonalAccessTokenFieldName_PERSONAL_ACCESS_TOKEN_FIELD_NAME_CREATED_DATE: + return query.PersonalAccessTokenColumnCreationDate + case user.PersonalAccessTokenFieldName_PERSONAL_ACCESS_TOKEN_FIELD_NAME_EXPIRATION_DATE: + return query.PersonalAccessTokenColumnExpiration + default: + return query.PersonalAccessTokenColumnCreationDate + } +} diff --git a/internal/api/grpc/user/v2/secret.go b/internal/api/grpc/user/v2/secret.go new file mode 100644 index 0000000000..1d54e1dde8 --- /dev/null +++ b/internal/api/grpc/user/v2/secret.go @@ -0,0 +1,39 @@ +package user + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func (s *Server) AddSecret(ctx context.Context, req *user.AddSecretRequest) (*user.AddSecretResponse, error) { + newSecret := &command.GenerateMachineSecret{ + PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), + } + details, err := s.command.GenerateMachineSecret(ctx, req.UserId, "", newSecret) + if err != nil { + return nil, err + } + return &user.AddSecretResponse{ + CreationDate: timestamppb.New(details.EventDate), + ClientSecret: newSecret.ClientSecret, + }, nil +} + +func (s *Server) RemoveSecret(ctx context.Context, req *user.RemoveSecretRequest) (*user.RemoveSecretResponse, error) { + details, err := s.command.RemoveMachineSecret( + ctx, + req.UserId, + "", + s.command.NewPermissionCheckUserWrite(ctx), + ) + if err != nil { + return nil, err + } + return &user.RemoveSecretResponse{ + DeletionDate: timestamppb.New(details.EventDate), + }, nil +} diff --git a/internal/api/grpc/user/v2/server.go b/internal/api/grpc/user/v2/server.go index 9272ea27ee..e3c7e8011e 100644 --- a/internal/api/grpc/user/v2/server.go +++ b/internal/api/grpc/user/v2/server.go @@ -8,6 +8,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" @@ -18,12 +19,13 @@ var _ user.UserServiceServer = (*Server)(nil) type Server struct { user.UnimplementedUserServiceServer - command *command.Commands - query *query.Queries - userCodeAlg crypto.EncryptionAlgorithm - idpAlg crypto.EncryptionAlgorithm - idpCallback func(ctx context.Context) string - samlRootURL func(ctx context.Context, idpID string) string + systemDefaults systemdefaults.SystemDefaults + command *command.Commands + query *query.Queries + userCodeAlg crypto.EncryptionAlgorithm + idpAlg crypto.EncryptionAlgorithm + idpCallback func(ctx context.Context) string + samlRootURL func(ctx context.Context, idpID string) string assetAPIPrefix func(context.Context) string @@ -33,6 +35,7 @@ type Server struct { type Config struct{} func CreateServer( + systemDefaults systemdefaults.SystemDefaults, command *command.Commands, query *query.Queries, userCodeAlg crypto.EncryptionAlgorithm, @@ -43,6 +46,7 @@ func CreateServer( checkPermission domain.PermissionCheck, ) *Server { return &Server{ + systemDefaults: systemDefaults, command: command, query: query, userCodeAlg: userCodeAlg, diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index 0f958f0d40..6b4b2da75b 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -11,6 +11,7 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) @@ -117,7 +118,7 @@ func genderToDomain(gender user.Gender) domain.Gender { } func (s *Server) UpdateHumanUser(ctx context.Context, req *user.UpdateHumanUserRequest) (_ *user.UpdateHumanUserResponse, err error) { - human, err := UpdateUserRequestToChangeHuman(req) + human, err := updateHumanUserRequestToChangeHuman(req) if err != nil { return nil, err } @@ -181,86 +182,6 @@ func ifNotNilPtr[v, p any](value *v, conv func(v) p) *p { return &pVal } -func UpdateUserRequestToChangeHuman(req *user.UpdateHumanUserRequest) (*command.ChangeHuman, error) { - email, err := SetHumanEmailToEmail(req.Email, req.GetUserId()) - if err != nil { - return nil, err - } - return &command.ChangeHuman{ - ID: req.GetUserId(), - Username: req.Username, - Profile: SetHumanProfileToProfile(req.Profile), - Email: email, - Phone: SetHumanPhoneToPhone(req.Phone), - Password: SetHumanPasswordToPassword(req.Password), - }, nil -} - -func SetHumanProfileToProfile(profile *user.SetHumanProfile) *command.Profile { - if profile == nil { - return nil - } - var firstName *string - if profile.GivenName != "" { - firstName = &profile.GivenName - } - var lastName *string - if profile.FamilyName != "" { - lastName = &profile.FamilyName - } - return &command.Profile{ - FirstName: firstName, - LastName: lastName, - NickName: profile.NickName, - DisplayName: profile.DisplayName, - PreferredLanguage: ifNotNilPtr(profile.PreferredLanguage, language.Make), - Gender: ifNotNilPtr(profile.Gender, genderToDomain), - } -} - -func SetHumanEmailToEmail(email *user.SetHumanEmail, userID string) (*command.Email, error) { - if email == nil { - return nil, nil - } - var urlTemplate string - if email.GetSendCode() != nil && email.GetSendCode().UrlTemplate != nil { - urlTemplate = *email.GetSendCode().UrlTemplate - if err := domain.RenderConfirmURLTemplate(io.Discard, urlTemplate, userID, "code", "orgID"); err != nil { - return nil, err - } - } - return &command.Email{ - Address: domain.EmailAddress(email.Email), - Verified: email.GetIsVerified(), - ReturnCode: email.GetReturnCode() != nil, - URLTemplate: urlTemplate, - }, nil -} - -func SetHumanPhoneToPhone(phone *user.SetHumanPhone) *command.Phone { - if phone == nil { - return nil - } - return &command.Phone{ - Number: domain.PhoneNumber(phone.GetPhone()), - Verified: phone.GetIsVerified(), - ReturnCode: phone.GetReturnCode() != nil, - } -} - -func SetHumanPasswordToPassword(password *user.SetPassword) *command.Password { - if password == nil { - return nil - } - return &command.Password{ - PasswordCode: password.GetVerificationCode(), - OldPassword: password.GetCurrentPassword(), - Password: password.GetPassword().GetPassword(), - EncodedPasswordHash: password.GetHashedPassword().GetHash(), - ChangeRequired: password.GetPassword().GetChangeRequired() || password.GetHashedPassword().GetChangeRequired(), - } -} - func (s *Server) DeleteUser(ctx context.Context, req *user.DeleteUserRequest) (_ *user.DeleteUserResponse, err error) { memberships, grants, err := s.removeUserDependencies(ctx, req.GetUserId()) if err != nil { @@ -482,3 +403,25 @@ func (s *Server) HumanMFAInitSkipped(ctx context.Context, req *user.HumanMFAInit Details: object.DomainToDetailsPb(details), }, nil } + +func (s *Server) CreateUser(ctx context.Context, req *user.CreateUserRequest) (*user.CreateUserResponse, error) { + switch userType := req.GetUserType().(type) { + case *user.CreateUserRequest_Human_: + return s.createUserTypeHuman(ctx, userType.Human, req.OrganizationId, req.Username, req.UserId) + case *user.CreateUserRequest_Machine_: + return s.createUserTypeMachine(ctx, userType.Machine, req.OrganizationId, req.GetUsername(), req.GetUserId()) + default: + return nil, zerrors.ThrowInternal(nil, "", "user type is not implemented") + } +} + +func (s *Server) UpdateUser(ctx context.Context, req *user.UpdateUserRequest) (*user.UpdateUserResponse, error) { + switch userType := req.GetUserType().(type) { + case *user.UpdateUserRequest_Human_: + return s.updateUserTypeHuman(ctx, userType.Human, req.UserId, req.Username) + case *user.UpdateUserRequest_Machine_: + return s.updateUserTypeMachine(ctx, userType.Machine, req.UserId, req.Username) + default: + return nil, zerrors.ThrowUnimplemented(nil, "", "user type is not implemented") + } +} diff --git a/internal/api/grpc/user/v2/query.go b/internal/api/grpc/user/v2/user_query.go similarity index 100% rename from internal/api/grpc/user/v2/query.go rename to internal/api/grpc/user/v2/user_query.go diff --git a/internal/api/scim/resources/user.go b/internal/api/scim/resources/user.go index bc8d864994..13baed5d51 100644 --- a/internal/api/scim/resources/user.go +++ b/internal/api/scim/resources/user.go @@ -203,7 +203,6 @@ func (h *UsersHandler) Delete(ctx context.Context, id string) error { if err != nil { return err } - _, err = h.command.RemoveUserV2(ctx, id, authz.GetCtxData(ctx).OrgID, memberships, grants...) return err } diff --git a/internal/command/instance_member.go b/internal/command/instance_member.go index ee9bf15f84..a33635e8f5 100644 --- a/internal/command/instance_member.go +++ b/internal/command/instance_member.go @@ -22,7 +22,7 @@ func (c *Commands) AddInstanceMemberCommand(a *instance.Aggregate, userID string return nil, zerrors.ThrowInvalidArgument(nil, "INSTANCE-4m0fS", "Errors.IAM.MemberInvalid") } return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { - if exists, err := ExistsUser(ctx, filter, userID, ""); err != nil || !exists { + if exists, err := ExistsUser(ctx, filter, userID, "", false); err != nil || !exists { return nil, zerrors.ThrowPreconditionFailed(err, "INSTA-GSXOn", "Errors.User.NotFound") } if isMember, err := IsInstanceMember(ctx, filter, a.ID, userID); err != nil || isMember { diff --git a/internal/command/org_member.go b/internal/command/org_member.go index ae9bef2151..bf1ae91d8a 100644 --- a/internal/command/org_member.go +++ b/internal/command/org_member.go @@ -28,7 +28,7 @@ func (c *Commands) AddOrgMemberCommand(a *org.Aggregate, userID string, roles .. ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if exists, err := ExistsUser(ctx, filter, userID, ""); err != nil || !exists { + if exists, err := ExistsUser(ctx, filter, userID, "", false); err != nil || !exists { return nil, zerrors.ThrowPreconditionFailed(err, "ORG-GoXOn", "Errors.User.NotFound") } if isMember, err := IsOrgMember(ctx, filter, a.ID, userID); err != nil || isMember { diff --git a/internal/command/resource_ower_model.go b/internal/command/resource_owner_model.go similarity index 100% rename from internal/command/resource_ower_model.go rename to internal/command/resource_owner_model.go diff --git a/internal/command/user.go b/internal/command/user.go index 6b65aa83ec..0db4fda328 100644 --- a/internal/command/user.go +++ b/internal/command/user.go @@ -353,21 +353,27 @@ func (c *Commands) userWriteModelByID(ctx context.Context, userID, resourceOwner return writeModel, nil } -func ExistsUser(ctx context.Context, filter preparation.FilterToQueryReducer, id, resourceOwner string) (exists bool, err error) { +func ExistsUser(ctx context.Context, filter preparation.FilterToQueryReducer, id, resourceOwner string, machineOnly bool) (exists bool, err error) { + eventTypes := []eventstore.EventType{ + user.MachineAddedEventType, + user.UserRemovedType, + } + if !machineOnly { + eventTypes = append(eventTypes, + user.HumanRegisteredType, + user.UserV1RegisteredType, + user.HumanAddedType, + user.UserV1AddedType, + ) + } events, err := filter(ctx, eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). ResourceOwner(resourceOwner). OrderAsc(). AddQuery(). AggregateTypes(user.AggregateType). AggregateIDs(id). - EventTypes( - user.HumanRegisteredType, - user.UserV1RegisteredType, - user.HumanAddedType, - user.UserV1AddedType, - user.MachineAddedEventType, - user.UserRemovedType, - ).Builder()) + EventTypes(eventTypes...). + Builder()) if err != nil { return false, err } diff --git a/internal/command/user_machine.go b/internal/command/user_machine.go index 1ec32450ac..7c8fd89eac 100644 --- a/internal/command/user_machine.go +++ b/internal/command/user_machine.go @@ -25,6 +25,7 @@ type Machine struct { Name string Description string AccessTokenType domain.OIDCTokenType + PermissionCheck PermissionCheck } func (m *Machine) IsZero() bool { @@ -33,8 +34,8 @@ func (m *Machine) IsZero() bool { func AddMachineCommand(a *user.Aggregate, machine *Machine) preparation.Validation { return func() (_ preparation.CreateCommands, err error) { - if a.ResourceOwner == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-xiown2", "Errors.ResourceOwnerMissing") + if a.ResourceOwner == "" && machine.PermissionCheck == nil { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-xiown3", "Errors.ResourceOwnerMissing") } if a.ID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-p0p2mi", "Errors.User.UserIDMissing") @@ -49,7 +50,7 @@ func AddMachineCommand(a *user.Aggregate, machine *Machine) preparation.Validati ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter) + writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter, machine.PermissionCheck) if err != nil { return nil, err } @@ -67,7 +68,18 @@ func AddMachineCommand(a *user.Aggregate, machine *Machine) preparation.Validati } } -func (c *Commands) AddMachine(ctx context.Context, machine *Machine) (_ *domain.ObjectDetails, err error) { +type addMachineOption func(context.Context, *Machine) error + +func AddMachineWithUsernameToIDFallback() addMachineOption { + return func(ctx context.Context, m *Machine) error { + if m.Username == "" { + m.Username = m.AggregateID + } + return nil + } +} + +func (c *Commands) AddMachine(ctx context.Context, machine *Machine, check PermissionCheck, options ...addMachineOption) (_ *domain.ObjectDetails, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -80,6 +92,16 @@ func (c *Commands) AddMachine(ctx context.Context, machine *Machine) (_ *domain. } agg := user.NewAggregate(machine.AggregateID, machine.ResourceOwner) + for _, option := range options { + if err = option(ctx, machine); err != nil { + return nil, err + } + } + if check != nil { + if err = check(machine.ResourceOwner, machine.AggregateID); err != nil { + return nil, err + } + } cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, AddMachineCommand(agg, machine)) if err != nil { return nil, err @@ -97,6 +119,7 @@ func (c *Commands) AddMachine(ctx context.Context, machine *Machine) (_ *domain. }, nil } +// Deprecated: use ChangeUserMachine instead func (c *Commands) ChangeMachine(ctx context.Context, machine *Machine) (*domain.ObjectDetails, error) { agg := user.NewAggregate(machine.AggregateID, machine.ResourceOwner) cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, changeMachineCommand(agg, machine)) @@ -118,24 +141,21 @@ func (c *Commands) ChangeMachine(ctx context.Context, machine *Machine) (*domain func changeMachineCommand(a *user.Aggregate, machine *Machine) preparation.Validation { return func() (_ preparation.CreateCommands, err error) { - if a.ResourceOwner == "" { + if a.ResourceOwner == "" && machine.PermissionCheck == nil { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-xiown3", "Errors.ResourceOwnerMissing") } if a.ID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-p0p3mi", "Errors.User.UserIDMissing") } return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { - writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter) + writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter, machine.PermissionCheck) if err != nil { return nil, err } if !isUserStateExists(writeModel.UserState) { return nil, zerrors.ThrowNotFound(nil, "COMMAND-5M0od", "Errors.User.NotFound") } - changedEvent, hasChanged, err := writeModel.NewChangedEvent(ctx, &a.Aggregate, machine.Name, machine.Description, machine.AccessTokenType) - if err != nil { - return nil, err - } + changedEvent, hasChanged := writeModel.NewChangedEvent(ctx, &a.Aggregate, machine.Name, machine.Description, machine.AccessTokenType) if !hasChanged { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-2n8vs", "Errors.User.NotChanged") } @@ -147,10 +167,9 @@ func changeMachineCommand(a *user.Aggregate, machine *Machine) preparation.Valid } } -func getMachineWriteModel(ctx context.Context, userID, resourceOwner string, filter preparation.FilterToQueryReducer) (_ *MachineWriteModel, err error) { +func getMachineWriteModel(ctx context.Context, userID, resourceOwner string, filter preparation.FilterToQueryReducer, permissionCheck PermissionCheck) (_ *MachineWriteModel, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - writeModel := NewMachineWriteModel(userID, resourceOwner) events, err := filter(ctx, writeModel.Query()) if err != nil { @@ -161,5 +180,10 @@ func getMachineWriteModel(ctx context.Context, userID, resourceOwner string, fil } writeModel.AppendEvents(events...) err = writeModel.Reduce() + if permissionCheck != nil { + if err := permissionCheck(writeModel.ResourceOwner, writeModel.AggregateID); err != nil { + return nil, err + } + } return writeModel, err } diff --git a/internal/command/user_machine_key.go b/internal/command/user_machine_key.go index 8a0f0f437b..d628bf4c2d 100644 --- a/internal/command/user_machine_key.go +++ b/internal/command/user_machine_key.go @@ -15,12 +15,14 @@ import ( ) type AddMachineKey struct { - Type domain.AuthNKeyType - ExpirationDate time.Time + Type domain.AuthNKeyType + ExpirationDate time.Time + PermissionCheck PermissionCheck } type MachineKey struct { models.ObjectRoot + PermissionCheck PermissionCheck KeyID string Type domain.AuthNKeyType @@ -64,7 +66,7 @@ func (key *MachineKey) Detail() ([]byte, error) { } func (key *MachineKey) content() error { - if key.ResourceOwner == "" { + if key.PermissionCheck == nil && key.ResourceOwner == "" { return zerrors.ThrowInvalidArgument(nil, "COMMAND-kqpoix", "Errors.ResourceOwnerMissing") } if key.AggregateID == "" { @@ -91,7 +93,7 @@ func (key *MachineKey) valid() (err error) { } func (key *MachineKey) checkAggregate(ctx context.Context, filter preparation.FilterToQueryReducer) error { - if exists, err := ExistsUser(ctx, filter, key.AggregateID, key.ResourceOwner); err != nil || !exists { + if exists, err := ExistsUser(ctx, filter, key.AggregateID, key.ResourceOwner, true); err != nil || !exists { return zerrors.ThrowPreconditionFailed(err, "COMMAND-bnipwm1", "Errors.User.NotFound") } return nil @@ -142,7 +144,7 @@ func prepareAddUserMachineKey(machineKey *MachineKey, keySize int) preparation.V return nil, err } } - writeModel, err := getMachineKeyWriteModelByID(ctx, filter, machineKey.AggregateID, machineKey.KeyID, machineKey.ResourceOwner) + writeModel, err := getMachineKeyWriteModelByID(ctx, filter, machineKey.AggregateID, machineKey.KeyID, machineKey.ResourceOwner, machineKey.PermissionCheck) if err != nil { return nil, err } @@ -186,7 +188,7 @@ func prepareRemoveUserMachineKey(machineKey *MachineKey) preparation.Validation return nil, err } return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { - writeModel, err := getMachineKeyWriteModelByID(ctx, filter, machineKey.AggregateID, machineKey.KeyID, machineKey.ResourceOwner) + writeModel, err := getMachineKeyWriteModelByID(ctx, filter, machineKey.AggregateID, machineKey.KeyID, machineKey.ResourceOwner, machineKey.PermissionCheck) if err != nil { return nil, err } @@ -204,16 +206,18 @@ func prepareRemoveUserMachineKey(machineKey *MachineKey) preparation.Validation } } -func getMachineKeyWriteModelByID(ctx context.Context, filter preparation.FilterToQueryReducer, userID, keyID, resourceOwner string) (_ *MachineKeyWriteModel, err error) { +func getMachineKeyWriteModelByID(ctx context.Context, filter preparation.FilterToQueryReducer, userID, keyID, resourceOwner string, permissionCheck PermissionCheck) (_ *MachineKeyWriteModel, err error) { writeModel := NewMachineKeyWriteModel(userID, keyID, resourceOwner) events, err := filter(ctx, writeModel.Query()) if err != nil { return nil, err } - if len(events) == 0 { - return writeModel, nil - } writeModel.AppendEvents(events...) err = writeModel.Reduce() + if permissionCheck != nil { + if err := permissionCheck(writeModel.ResourceOwner, writeModel.AggregateID); err != nil { + return nil, err + } + } return writeModel, err } diff --git a/internal/command/user_machine_model.go b/internal/command/user_machine_model.go index b7dfb02d32..1ed6c8ca58 100644 --- a/internal/command/user_machine_model.go +++ b/internal/command/user_machine_model.go @@ -2,7 +2,6 @@ package command import ( "context" - "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -106,9 +105,8 @@ func (wm *MachineWriteModel) NewChangedEvent( name, description string, accessTokenType domain.OIDCTokenType, -) (*user.MachineChangedEvent, bool, error) { +) (*user.MachineChangedEvent, bool) { changes := make([]user.MachineChanges, 0) - var err error if wm.Name != name { changes = append(changes, user.ChangeName(name)) @@ -120,11 +118,8 @@ func (wm *MachineWriteModel) NewChangedEvent( changes = append(changes, user.ChangeAccessTokenType(accessTokenType)) } if len(changes) == 0 { - return nil, false, nil + return nil, false } - changeEvent, err := user.NewMachineChangedEvent(ctx, aggregate, changes) - if err != nil { - return nil, false, err - } - return changeEvent, true, nil + changeEvent := user.NewMachineChangedEvent(ctx, aggregate, changes) + return changeEvent, true } diff --git a/internal/command/user_machine_secret.go b/internal/command/user_machine_secret.go index 3349fc90a5..34e9c0c5cc 100644 --- a/internal/command/user_machine_secret.go +++ b/internal/command/user_machine_secret.go @@ -11,7 +11,8 @@ import ( ) type GenerateMachineSecret struct { - ClientSecret string + PermissionCheck PermissionCheck + ClientSecret string } func (c *Commands) GenerateMachineSecret(ctx context.Context, userID string, resourceOwner string, set *GenerateMachineSecret) (*domain.ObjectDetails, error) { @@ -35,14 +36,14 @@ func (c *Commands) GenerateMachineSecret(ctx context.Context, userID string, res func (c *Commands) prepareGenerateMachineSecret(a *user.Aggregate, set *GenerateMachineSecret) preparation.Validation { return func() (_ preparation.CreateCommands, err error) { - if a.ResourceOwner == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-x0992n", "Errors.ResourceOwnerMissing") + if a.ResourceOwner == "" && set.PermissionCheck == nil { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-0qp2hus", "Errors.ResourceOwnerMissing") } if a.ID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-bzoqjs", "Errors.User.UserIDMissing") } return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { - writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter) + writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter, set.PermissionCheck) if err != nil { return nil, err } @@ -62,9 +63,10 @@ func (c *Commands) prepareGenerateMachineSecret(a *user.Aggregate, set *Generate } } -func (c *Commands) RemoveMachineSecret(ctx context.Context, userID string, resourceOwner string) (*domain.ObjectDetails, error) { +func (c *Commands) RemoveMachineSecret(ctx context.Context, userID string, resourceOwner string, permissionCheck PermissionCheck) (*domain.ObjectDetails, error) { agg := user.NewAggregate(userID, resourceOwner) - cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, prepareRemoveMachineSecret(agg)) + //nolint:staticcheck + cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, prepareRemoveMachineSecret(agg, permissionCheck)) if err != nil { return nil, err } @@ -81,16 +83,16 @@ func (c *Commands) RemoveMachineSecret(ctx context.Context, userID string, resou }, nil } -func prepareRemoveMachineSecret(a *user.Aggregate) preparation.Validation { +func prepareRemoveMachineSecret(a *user.Aggregate, check PermissionCheck) preparation.Validation { return func() (_ preparation.CreateCommands, err error) { - if a.ResourceOwner == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-0qp2hus", "Errors.ResourceOwnerMissing") + if a.ResourceOwner == "" && check == nil { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-x0992n", "Errors.ResourceOwnerMissing") } if a.ID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-bzosjs", "Errors.User.UserIDMissing") } return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { - writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter) + writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter, check) if err != nil { return nil, err } diff --git a/internal/command/user_machine_secret_test.go b/internal/command/user_machine_secret_test.go index 4c6d16960c..8e839efe07 100644 --- a/internal/command/user_machine_secret_test.go +++ b/internal/command/user_machine_secret_test.go @@ -44,7 +44,7 @@ func TestCommandSide_GenerateMachineSecret(t *testing.T) { ctx: context.Background(), userID: "", resourceOwner: "org1", - set: nil, + set: new(GenerateMachineSecret), }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -59,7 +59,7 @@ func TestCommandSide_GenerateMachineSecret(t *testing.T) { ctx: context.Background(), userID: "user1", resourceOwner: "", - set: nil, + set: new(GenerateMachineSecret), }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -76,7 +76,7 @@ func TestCommandSide_GenerateMachineSecret(t *testing.T) { ctx: context.Background(), userID: "user1", resourceOwner: "org1", - set: nil, + set: new(GenerateMachineSecret), }, res: res{ err: zerrors.IsPreconditionFailed, @@ -289,7 +289,7 @@ func TestCommandSide_RemoveMachineSecret(t *testing.T) { r := &Commands{ eventstore: tt.fields.eventstore, } - got, err := r.RemoveMachineSecret(tt.args.ctx, tt.args.userID, tt.args.resourceOwner) + got, err := r.RemoveMachineSecret(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, nil) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/command/user_machine_test.go b/internal/command/user_machine_test.go index c7b4b8caf4..19548ae9c6 100644 --- a/internal/command/user_machine_test.go +++ b/internal/command/user_machine_test.go @@ -24,6 +24,8 @@ func TestCommandSide_AddMachine(t *testing.T) { type args struct { ctx context.Context machine *Machine + check PermissionCheck + options func(*Commands) []addMachineOption } type res struct { want *domain.ObjectDetails @@ -194,14 +196,242 @@ func TestCommandSide_AddMachine(t *testing.T) { }, }, }, + { + name: "with username fallback to given username", + fields: fields{ + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "aggregateID"), + eventstore: eventstoreExpect( + t, + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("aggregateID", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("aggregateID", "org1").Aggregate, + "username", + "name", + "", + true, + domain.OIDCTokenTypeBearer, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + machine: &Machine{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + Name: "name", + Username: "username", + }, + options: func(commands *Commands) []addMachineOption { + return []addMachineOption{ + AddMachineWithUsernameToIDFallback(), + } + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "with username fallback to generated id", + fields: fields{ + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "aggregateID"), + eventstore: eventstoreExpect( + t, + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("aggregateID", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("aggregateID", "org1").Aggregate, + "aggregateID", + "name", + "", + true, + domain.OIDCTokenTypeBearer, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + machine: &Machine{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + Name: "name", + }, + options: func(commands *Commands) []addMachineOption { + return []addMachineOption{ + AddMachineWithUsernameToIDFallback(), + } + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "with username fallback to given id", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("aggregateID", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("aggregateID", "org1").Aggregate, + "aggregateID", + "name", + "", + true, + domain.OIDCTokenTypeBearer, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + machine: &Machine{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + AggregateID: "aggregateID", + }, + Name: "name", + }, + options: func(commands *Commands) []addMachineOption { + return []addMachineOption{ + AddMachineWithUsernameToIDFallback(), + } + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "with succeeding permission check, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "name", + "description", + true, + domain.OIDCTokenTypeBearer, + ), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + }, + args: args{ + ctx: context.Background(), + machine: &Machine{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + Description: "description", + Name: "name", + Username: "username", + }, + check: func(resourceOwner, aggregateID string) error { + return nil + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "with failing permission check, error", + fields: fields{ + eventstore: eventstoreExpect( + t, + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + }, + args: args{ + ctx: context.Background(), + machine: &Machine{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + Description: "description", + Name: "name", + Username: "username", + }, + check: func(resourceOwner, aggregateID string) error { + return zerrors.ThrowPermissionDenied(nil, "", "") + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - idGenerator: tt.fields.idGenerator, + eventstore: tt.fields.eventstore, + idGenerator: tt.fields.idGenerator, + checkPermission: newMockPermissionCheckAllowed(), } - got, err := r.AddMachine(tt.args.ctx, tt.args.machine) + var options []addMachineOption + if tt.args.options != nil { + options = tt.args.options(r) + } + got, err := r.AddMachine(tt.args.ctx, tt.args.machine, tt.args.check, options...) if tt.res.err == nil { assert.NoError(t, err) } @@ -391,7 +621,7 @@ func TestCommandSide_ChangeMachine(t *testing.T) { } func newMachineChangedEvent(ctx context.Context, userID, resourceOwner, name, description string) *user.MachineChangedEvent { - event, _ := user.NewMachineChangedEvent(ctx, + event := user.NewMachineChangedEvent(ctx, &user.NewAggregate(userID, resourceOwner).Aggregate, []user.MachineChanges{ user.ChangeName(name), diff --git a/internal/command/user_personal_access_token.go b/internal/command/user_personal_access_token.go index 0faf85d5eb..f37953f3d6 100644 --- a/internal/command/user_personal_access_token.go +++ b/internal/command/user_personal_access_token.go @@ -21,6 +21,7 @@ type AddPat struct { type PersonalAccessToken struct { models.ObjectRoot + PermissionCheck PermissionCheck ExpirationDate time.Time Scopes []string @@ -43,7 +44,7 @@ func NewPersonalAccessToken(resourceOwner string, userID string, expirationDate } func (pat *PersonalAccessToken) content() error { - if pat.ResourceOwner == "" { + if pat.ResourceOwner == "" && pat.PermissionCheck == nil { return zerrors.ThrowInvalidArgument(nil, "COMMAND-xs0k2n", "Errors.ResourceOwnerMissing") } if pat.AggregateID == "" { @@ -109,11 +110,10 @@ func prepareAddPersonalAccessToken(pat *PersonalAccessToken, algorithm crypto.En if err := pat.checkAggregate(ctx, filter); err != nil { return nil, err } - writeModel, err := getPersonalAccessTokenWriteModelByID(ctx, filter, pat.AggregateID, pat.TokenID, pat.ResourceOwner) + writeModel, err := getPersonalAccessTokenWriteModelByID(ctx, filter, pat.AggregateID, pat.TokenID, pat.ResourceOwner, pat.PermissionCheck) if err != nil { return nil, err } - pat.Token, err = createToken(algorithm, writeModel.TokenID, writeModel.AggregateID) if err != nil { return nil, err @@ -155,7 +155,7 @@ func prepareRemovePersonalAccessToken(pat *PersonalAccessToken) preparation.Vali return nil, err } return func(ctx context.Context, filter preparation.FilterToQueryReducer) (_ []eventstore.Command, err error) { - writeModel, err := getPersonalAccessTokenWriteModelByID(ctx, filter, pat.AggregateID, pat.TokenID, pat.ResourceOwner) + writeModel, err := getPersonalAccessTokenWriteModelByID(ctx, filter, pat.AggregateID, pat.TokenID, pat.ResourceOwner, pat.PermissionCheck) if err != nil { return nil, err } @@ -181,16 +181,18 @@ func createToken(algorithm crypto.EncryptionAlgorithm, tokenID, userID string) ( return base64.RawURLEncoding.EncodeToString(encrypted), nil } -func getPersonalAccessTokenWriteModelByID(ctx context.Context, filter preparation.FilterToQueryReducer, userID, tokenID, resourceOwner string) (_ *PersonalAccessTokenWriteModel, err error) { +func getPersonalAccessTokenWriteModelByID(ctx context.Context, filter preparation.FilterToQueryReducer, userID, tokenID, resourceOwner string, check PermissionCheck) (_ *PersonalAccessTokenWriteModel, err error) { writeModel := NewPersonalAccessTokenWriteModel(userID, tokenID, resourceOwner) events, err := filter(ctx, writeModel.Query()) if err != nil { return nil, err } - if len(events) == 0 { - return writeModel, nil - } writeModel.AppendEvents(events...) - err = writeModel.Reduce() + if err = writeModel.Reduce(); err != nil { + return nil, err + } + if check != nil { + err = check(writeModel.ResourceOwner, writeModel.AggregateID) + } return writeModel, err } diff --git a/internal/command/user_test.go b/internal/command/user_test.go index 9abae187c1..6a1597fc8b 100644 --- a/internal/command/user_test.go +++ b/internal/command/user_test.go @@ -1813,7 +1813,7 @@ func TestExistsUser(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotExists, err := ExistsUser(context.Background(), tt.args.filter, tt.args.id, tt.args.resourceOwner) + gotExists, err := ExistsUser(context.Background(), tt.args.filter, tt.args.id, tt.args.resourceOwner, false) if (err != nil) != tt.wantErr { t.Errorf("ExistsUser() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/internal/command/user_v2.go b/internal/command/user_v2.go index 5f8e8d6ff5..be10fd03fe 100644 --- a/internal/command/user_v2.go +++ b/internal/command/user_v2.go @@ -132,7 +132,6 @@ func (c *Commands) RemoveUserV2(ctx context.Context, userID, resourceOwner strin if userID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-vaipl7s13l", "Errors.User.UserIDMissing") } - existingUser, err := c.userRemoveWriteModel(ctx, userID, resourceOwner) if err != nil { return nil, err @@ -143,7 +142,6 @@ func (c *Commands) RemoveUserV2(ctx context.Context, userID, resourceOwner strin if err := c.checkPermissionDeleteUser(ctx, existingUser.ResourceOwner, existingUser.AggregateID); err != nil { return nil, err } - domainPolicy, err := c.domainPolicyWriteModel(ctx, existingUser.ResourceOwner) if err != nil { return nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-l40ykb3xh2", "Errors.Org.DomainPolicy.NotExisting") diff --git a/internal/command/user_v2_human.go b/internal/command/user_v2_human.go index f88e2017d5..0945ae7578 100644 --- a/internal/command/user_v2_human.go +++ b/internal/command/user_v2_human.go @@ -5,6 +5,7 @@ import ( "golang.org/x/text/language" + "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -121,7 +122,10 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human if resourceOwner == "" { return zerrors.ThrowInvalidArgument(nil, "COMMA-095xh8fll1", "Errors.Internal") } - + if human.Details == nil { + human.Details = &domain.ObjectDetails{} + } + human.Details.ResourceOwner = resourceOwner if err := human.Validate(c.userPasswordHasher); err != nil { return err } @@ -132,7 +136,12 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human return err } } - + // check for permission to create user on resourceOwner + if !human.Register { + if err := c.checkPermissionUpdateUser(ctx, resourceOwner, human.ID); err != nil { + return err + } + } // only check if user is already existing existingHuman, err := c.userExistsWriteModel( ctx, @@ -144,12 +153,6 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human if isUserStateExists(existingHuman.UserState) { return zerrors.ThrowPreconditionFailed(nil, "COMMAND-7yiox1isql", "Errors.User.AlreadyExisting") } - // check for permission to create user on resourceOwner - if !human.Register { - if err := c.checkPermission(ctx, domain.PermissionUserWrite, resourceOwner, human.ID); err != nil { - return err - } - } // add resourceowner for the events with the aggregate existingHuman.ResourceOwner = resourceOwner @@ -161,6 +164,7 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human if err = c.userValidateDomain(ctx, resourceOwner, human.Username, domainPolicy.UserLoginMustBeDomain); err != nil { return err } + var createCmd humanCreationCommand if human.Register { createCmd = user.NewHumanRegisteredEvent( @@ -203,17 +207,33 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human return err } - cmds := make([]eventstore.Command, 0, 3) - cmds = append(cmds, createCmd) - - cmds, err = c.addHumanCommandEmail(ctx, filter, cmds, existingHuman.Aggregate(), human, alg, allowInitMail) + cmds, err := c.addUserHumanCommands(ctx, filter, existingHuman, human, allowInitMail, alg, createCmd) if err != nil { return err } + if len(cmds) == 0 { + human.Details = writeModelToObjectDetails(&existingHuman.WriteModel) + return nil + } + err = c.pushAppendAndReduce(ctx, existingHuman, cmds...) + if err != nil { + return err + } + human.Details = writeModelToObjectDetails(&existingHuman.WriteModel) + return nil +} + +func (c *Commands) addUserHumanCommands(ctx context.Context, filter preparation.FilterToQueryReducer, existingHuman *UserV2WriteModel, human *AddHuman, allowInitMail bool, alg crypto.EncryptionAlgorithm, addUserCommand eventstore.Command) ([]eventstore.Command, error) { + cmds := []eventstore.Command{addUserCommand} + var err error + cmds, err = c.addHumanCommandEmail(ctx, filter, cmds, existingHuman.Aggregate(), human, alg, allowInitMail) + if err != nil { + return nil, err + } cmds, err = c.addHumanCommandPhone(ctx, filter, cmds, existingHuman.Aggregate(), human, alg) if err != nil { - return err + return nil, err } for _, metadataEntry := range human.Metadata { @@ -227,7 +247,7 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human for _, link := range human.Links { cmd, err := addLink(ctx, filter, existingHuman.Aggregate(), link) if err != nil { - return err + return nil, err } cmds = append(cmds, cmd) } @@ -235,7 +255,7 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human if human.TOTPSecret != "" { encryptedSecret, err := crypto.Encrypt([]byte(human.TOTPSecret), c.multifactors.OTP.CryptoMFA) if err != nil { - return err + return nil, err } cmds = append(cmds, user.NewHumanOTPAddedEvent(ctx, &existingHuman.Aggregate().Aggregate, encryptedSecret), @@ -246,18 +266,7 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human if human.SetInactive { cmds = append(cmds, user.NewUserDeactivatedEvent(ctx, &existingHuman.Aggregate().Aggregate)) } - - if len(cmds) == 0 { - human.Details = writeModelToObjectDetails(&existingHuman.WriteModel) - return nil - } - - err = c.pushAppendAndReduce(ctx, existingHuman, cmds...) - if err != nil { - return err - } - human.Details = writeModelToObjectDetails(&existingHuman.WriteModel) - return nil + return cmds, nil } func (c *Commands) ChangeUserHuman(ctx context.Context, human *ChangeHuman, alg crypto.EncryptionAlgorithm) (err error) { @@ -341,7 +350,6 @@ func (c *Commands) ChangeUserHuman(ctx context.Context, human *ChangeHuman, alg if human.State != nil { // only allow toggling between active and inactive // any other target state is not supported - // the existing human's state has to be the switch { case isUserStateActive(*human.State): if isUserStateActive(existingHuman.UserState) { diff --git a/internal/command/user_v2_human_test.go b/internal/command/user_v2_human_test.go index 2b4399fb2a..e44e182b92 100644 --- a/internal/command/user_v2_human_test.go +++ b/internal/command/user_v2_human_test.go @@ -302,9 +302,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { { name: "add human (with initial code), no permission", fields: fields{ - eventstore: expectEventstore( - expectFilter(), - ), + eventstore: expectEventstore(), checkPermission: newMockPermissionCheckNotAllowed(), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), newCode: mockEncryptedCode("userinit", time.Hour), @@ -326,9 +324,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, res: res{ - err: func(err error) bool { - return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) - }, + err: zerrors.IsPermissionDenied, }, }, { diff --git a/internal/command/user_v2_invite_test.go b/internal/command/user_v2_invite_test.go index 04c00d876e..75bd3157db 100644 --- a/internal/command/user_v2_invite_test.go +++ b/internal/command/user_v2_invite_test.go @@ -352,6 +352,7 @@ func TestCommands_ResendInviteCode(t *testing.T) { "user does not exist", fields{ eventstore: expectEventstore( + // The write model doesn't query any events expectFilter(), ), checkPermission: newMockPermissionCheckAllowed(), diff --git a/internal/command/user_v2_machine.go b/internal/command/user_v2_machine.go new file mode 100644 index 0000000000..34079b7e6f --- /dev/null +++ b/internal/command/user_v2_machine.go @@ -0,0 +1,94 @@ +package command + +import ( + "context" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type ChangeMachine struct { + ID string + ResourceOwner string + Username *string + Name *string + Description *string + + // Details are set after a successful execution of the command + Details *domain.ObjectDetails +} + +func (h *ChangeMachine) Changed() bool { + if h.Username != nil { + return true + } + if h.Name != nil { + return true + } + if h.Description != nil { + return true + } + return false +} + +func (c *Commands) ChangeUserMachine(ctx context.Context, machine *ChangeMachine) (err error) { + existingMachine, err := c.UserMachineWriteModel( + ctx, + machine.ID, + machine.ResourceOwner, + false, + ) + if err != nil { + return err + } + if machine.Changed() { + if err := c.checkPermissionUpdateUser(ctx, existingMachine.ResourceOwner, existingMachine.AggregateID); err != nil { + return err + } + } + + cmds := make([]eventstore.Command, 0) + if machine.Username != nil { + cmds, err = c.changeUsername(ctx, cmds, existingMachine, *machine.Username) + if err != nil { + return err + } + } + var machineChanges []user.MachineChanges + if machine.Name != nil && *machine.Name != existingMachine.Name { + machineChanges = append(machineChanges, user.ChangeName(*machine.Name)) + } + if machine.Description != nil && *machine.Description != existingMachine.Description { + machineChanges = append(machineChanges, user.ChangeDescription(*machine.Description)) + } + if len(machineChanges) > 0 { + cmds = append(cmds, user.NewMachineChangedEvent(ctx, &existingMachine.Aggregate().Aggregate, machineChanges)) + } + if len(cmds) == 0 { + machine.Details = writeModelToObjectDetails(&existingMachine.WriteModel) + return nil + } + err = c.pushAppendAndReduce(ctx, existingMachine, cmds...) + if err != nil { + return err + } + machine.Details = writeModelToObjectDetails(&existingMachine.WriteModel) + return nil +} + +func (c *Commands) UserMachineWriteModel(ctx context.Context, userID, resourceOwner string, metadataWM bool) (writeModel *UserV2WriteModel, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + writeModel = NewUserMachineWriteModel(userID, resourceOwner, metadataWM) + err = c.eventstore.FilterToQueryReducer(ctx, writeModel) + if err != nil { + return nil, err + } + if !isUserStateExists(writeModel.UserState) { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-ugjs0upun6", "Errors.User.NotFound") + } + return writeModel, nil +} diff --git a/internal/command/user_v2_machine_test.go b/internal/command/user_v2_machine_test.go new file mode 100644 index 0000000000..14df4bfae7 --- /dev/null +++ b/internal/command/user_v2_machine_test.go @@ -0,0 +1,260 @@ +package command + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommandSide_ChangeUserMachine(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + ctx context.Context + orgID string + machine *ChangeMachine + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + + userAgg := user.NewAggregate("user1", "org1") + userAddedEvent := user.NewMachineAddedEvent(context.Background(), + &userAgg.Aggregate, + "username", + "name", + "description", + true, + domain.OIDCTokenTypeBearer, + ) + + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "change machine username, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(userAddedEvent), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + machine: &ChangeMachine{ + Username: gu.Ptr("changed"), + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + name: "change machine username, not found", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + machine: &ChangeMachine{ + Username: gu.Ptr("changed"), + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-ugjs0upun6", "Errors.User.NotFound")) + }, + }, + }, + { + name: "change machine username, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(userAddedEvent), + ), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + user.NewUsernameChangedEvent(context.Background(), + &userAgg.Aggregate, + "username", + "changed", + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + machine: &ChangeMachine{ + Username: gu.Ptr("changed"), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + { + name: "change machine username, no change", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(userAddedEvent), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + machine: &ChangeMachine{ + Username: gu.Ptr("username"), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + { + name: "change machine description, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(userAddedEvent), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + machine: &ChangeMachine{ + Description: gu.Ptr("changed"), + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + name: "change machine description, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(userAddedEvent), + ), + expectPush( + user.NewMachineChangedEvent(context.Background(), + &userAgg.Aggregate, + []user.MachineChanges{ + user.ChangeDescription("changed"), + }, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + machine: &ChangeMachine{ + Description: gu.Ptr("changed"), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "change machine description, no change", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(userAddedEvent), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + machine: &ChangeMachine{ + Description: gu.Ptr("description"), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + } + err := r.ChangeUserMachine(tt.args.ctx, tt.args.machine) + if tt.res.err == nil { + if !assert.NoError(t, err) { + t.FailNow() + } + } else if !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + return + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.want, tt.args.machine.Details) + } + }) + } +} diff --git a/internal/command/user_v2_model.go b/internal/command/user_v2_model.go index 214a2a5f9d..92346bf3b6 100644 --- a/internal/command/user_v2_model.go +++ b/internal/command/user_v2_model.go @@ -118,6 +118,14 @@ func NewUserHumanWriteModel(userID, resourceOwner string, profileWM, emailWM, ph return newUserV2WriteModel(userID, resourceOwner, opts...) } +func NewUserMachineWriteModel(userID, resourceOwner string, metadataListWM bool) *UserV2WriteModel { + opts := []UserV2WMOption{WithMachine(), WithState()} + if metadataListWM { + opts = append(opts, WithMetadata()) + } + return newUserV2WriteModel(userID, resourceOwner, opts...) +} + func newUserV2WriteModel(userID, resourceOwner string, opts ...UserV2WMOption) *UserV2WriteModel { wm := &UserV2WriteModel{ WriteModel: eventstore.WriteModel{ diff --git a/internal/domain/permission.go b/internal/domain/permission.go index fd300f63b9..bb569955f5 100644 --- a/internal/domain/permission.go +++ b/internal/domain/permission.go @@ -24,7 +24,7 @@ func (p *Permissions) appendPermission(ctxID, permission string) { p.Permissions = append(p.Permissions, permission) } -type PermissionCheck func(ctx context.Context, permission, orgID, resourceID string) (err error) +type PermissionCheck func(ctx context.Context, permission, resourceOwnerID, aggregateID string) (err error) const ( PermissionUserWrite = "user.write" diff --git a/internal/eventstore/write_model.go b/internal/eventstore/write_model.go index 277e65ed82..965fb16d0e 100644 --- a/internal/eventstore/write_model.go +++ b/internal/eventstore/write_model.go @@ -1,6 +1,8 @@ package eventstore -import "time" +import ( + "time" +) // WriteModel is the minimum representation of a command side write model. // It implements a basic reducer @@ -27,21 +29,25 @@ func (wm *WriteModel) Reduce() error { return nil } + latestEvent := wm.Events[len(wm.Events)-1] if wm.AggregateID == "" { - wm.AggregateID = wm.Events[0].Aggregate().ID - } - if wm.ResourceOwner == "" { - wm.ResourceOwner = wm.Events[0].Aggregate().ResourceOwner - } - if wm.InstanceID == "" { - wm.InstanceID = wm.Events[0].Aggregate().InstanceID + wm.AggregateID = latestEvent.Aggregate().ID } - wm.ProcessedSequence = wm.Events[len(wm.Events)-1].Sequence() - wm.ChangeDate = wm.Events[len(wm.Events)-1].CreatedAt() + if wm.ResourceOwner == "" { + wm.ResourceOwner = latestEvent.Aggregate().ResourceOwner + } + + if wm.InstanceID == "" { + wm.InstanceID = latestEvent.Aggregate().InstanceID + } + + wm.ProcessedSequence = latestEvent.Sequence() + wm.ChangeDate = latestEvent.CreatedAt() // all events processed and not needed anymore wm.Events = nil wm.Events = []Event{} + return nil } diff --git a/internal/integration/client.go b/internal/integration/client.go index 320809a7e8..3bf794f5f6 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -141,6 +141,7 @@ func (c *Client) pollHealth(ctx context.Context) (err error) { } } +// Deprecated: use CreateUserTypeHuman instead func (i *Instance) CreateHumanUser(ctx context.Context) *user_v2.AddHumanUserResponse { resp, err := i.Client.UserV2.AddHumanUser(ctx, &user_v2.AddHumanUserRequest{ Organization: &object.Organization{ @@ -172,6 +173,7 @@ func (i *Instance) CreateHumanUser(ctx context.Context) *user_v2.AddHumanUserRes return resp } +// Deprecated: user CreateUserTypeHuman instead func (i *Instance) CreateHumanUserNoPhone(ctx context.Context) *user_v2.AddHumanUserResponse { resp, err := i.Client.UserV2.AddHumanUser(ctx, &user_v2.AddHumanUserRequest{ Organization: &object.Organization{ @@ -197,6 +199,7 @@ func (i *Instance) CreateHumanUserNoPhone(ctx context.Context) *user_v2.AddHuman return resp } +// Deprecated: user CreateUserTypeHuman instead func (i *Instance) CreateHumanUserWithTOTP(ctx context.Context, secret string) *user_v2.AddHumanUserResponse { resp, err := i.Client.UserV2.AddHumanUser(ctx, &user_v2.AddHumanUserRequest{ Organization: &object.Organization{ @@ -229,6 +232,43 @@ func (i *Instance) CreateHumanUserWithTOTP(ctx context.Context, secret string) * return resp } +func (i *Instance) CreateUserTypeHuman(ctx context.Context) *user_v2.CreateUserResponse { + resp, err := i.Client.UserV2.CreateUser(ctx, &user_v2.CreateUserRequest{ + OrganizationId: i.DefaultOrg.GetId(), + UserType: &user_v2.CreateUserRequest_Human_{ + Human: &user_v2.CreateUserRequest_Human{ + Profile: &user_v2.SetHumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + }, + Email: &user_v2.SetHumanEmail{ + Email: fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()), + Verification: &user_v2.SetHumanEmail_ReturnCode{ + ReturnCode: &user_v2.ReturnEmailVerificationCode{}, + }, + }, + }, + }, + }) + logging.OnError(err).Panic("create human user") + i.TriggerUserByID(ctx, resp.GetId()) + return resp +} + +func (i *Instance) CreateUserTypeMachine(ctx context.Context) *user_v2.CreateUserResponse { + resp, err := i.Client.UserV2.CreateUser(ctx, &user_v2.CreateUserRequest{ + OrganizationId: i.DefaultOrg.GetId(), + UserType: &user_v2.CreateUserRequest_Machine_{ + Machine: &user_v2.CreateUserRequest_Machine{ + Name: "machine", + }, + }, + }) + logging.OnError(err).Panic("create machine user") + i.TriggerUserByID(ctx, resp.GetId()) + return resp +} + // TriggerUserByID makes sure the user projection gets triggered after creation. func (i *Instance) TriggerUserByID(ctx context.Context, users ...string) { var wg sync.WaitGroup diff --git a/internal/query/authn_key.go b/internal/query/authn_key.go index 8075422e63..ffbe38e7ae 100644 --- a/internal/query/authn_key.go +++ b/internal/query/authn_key.go @@ -5,6 +5,7 @@ import ( "database/sql" _ "embed" "errors" + "slices" "time" sq "github.com/Masterminds/squirrel" @@ -18,6 +19,14 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) +func keysCheckPermission(ctx context.Context, keys *AuthNKeys, permissionCheck domain.PermissionCheck) { + keys.AuthNKeys = slices.DeleteFunc(keys.AuthNKeys, + func(key *AuthNKey) bool { + return userCheckPermission(ctx, key.ResourceOwner, key.AggregateID, permissionCheck) != nil + }, + ) +} + var ( authNKeyTable = table{ name: projection.AuthNKeyTable, @@ -84,6 +93,7 @@ type AuthNKeys struct { type AuthNKey struct { ID string + AggregateID string CreationDate time.Time ChangeDate time.Time ResourceOwner string @@ -124,12 +134,47 @@ func (q *AuthNKeySearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder return query } -func (q *Queries) SearchAuthNKeys(ctx context.Context, queries *AuthNKeySearchQueries, withOwnerRemoved bool) (authNKeys *AuthNKeys, err error) { +type JoinFilter int + +const ( + JoinFilterUnspecified JoinFilter = iota + JoinFilterApp + JoinFilterUserMachine +) + +// SearchAuthNKeys returns machine or app keys, depending on the join filter. +// If permissionCheck is nil, the keys are not filtered. +// If permissionCheck is not nil and the PermissionCheckV2 feature flag is false, the returned keys are filtered in-memory by the given permission check. +// If permissionCheck is not nil and the PermissionCheckV2 feature flag is true, the returned keys are filtered in the database. +func (q *Queries) SearchAuthNKeys(ctx context.Context, queries *AuthNKeySearchQueries, filter JoinFilter, permissionCheck domain.PermissionCheck) (authNKeys *AuthNKeys, err error) { + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + keys, err := q.searchAuthNKeys(ctx, queries, filter, permissionCheckV2) + if err != nil { + return nil, err + } + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { + keysCheckPermission(ctx, keys, permissionCheck) + } + return keys, nil +} + +func (q *Queries) searchAuthNKeys(ctx context.Context, queries *AuthNKeySearchQueries, joinFilter JoinFilter, permissionCheckV2 bool) (authNKeys *AuthNKeys, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() query, scan := prepareAuthNKeysQuery() query = queries.toQuery(query) + switch joinFilter { + case JoinFilterUnspecified: + // Select all authN keys + case JoinFilterApp: + joinCol := ProjectColumnID + query = query.Join(joinCol.table.identifier() + " ON " + AuthNKeyColumnIdentifier.identifier() + " = " + joinCol.identifier()) + case JoinFilterUserMachine: + joinCol := MachineUserIDCol + query = query.Join(joinCol.table.identifier() + " ON " + AuthNKeyColumnIdentifier.identifier() + " = " + joinCol.identifier()) + query = userPermissionCheckV2WithCustomColumns(ctx, query, permissionCheckV2, queries.Queries, AuthNKeyColumnResourceOwner, AuthNKeyColumnIdentifier) + } eq := sq.Eq{ AuthNKeyColumnEnabled.identifier(): true, AuthNKeyColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), @@ -249,6 +294,22 @@ func NewAuthNKeyObjectIDQuery(id string) (SearchQuery, error) { return NewTextQuery(AuthNKeyColumnObjectID, id, TextEquals) } +func NewAuthNKeyIDQuery(id string) (SearchQuery, error) { + return NewTextQuery(AuthNKeyColumnID, id, TextEquals) +} + +func NewAuthNKeyIdentifyerQuery(id string) (SearchQuery, error) { + return NewTextQuery(AuthNKeyColumnIdentifier, id, TextEquals) +} + +func NewAuthNKeyCreationDateQuery(ts time.Time, compare TimestampComparison) (SearchQuery, error) { + return NewTimestampQuery(AuthNKeyColumnCreationDate, ts, compare) +} + +func NewAuthNKeyExpirationDateDateQuery(ts time.Time, compare TimestampComparison) (SearchQuery, error) { + return NewTimestampQuery(AuthNKeyColumnExpiration, ts, compare) +} + //go:embed authn_key_user.sql var authNKeyUserQuery string @@ -288,49 +349,52 @@ func (q *Queries) GetAuthNKeyUser(ctx context.Context, keyID, userID string) (_ } func prepareAuthNKeysQuery() (sq.SelectBuilder, func(rows *sql.Rows) (*AuthNKeys, error)) { - return sq.Select( - AuthNKeyColumnID.identifier(), - AuthNKeyColumnCreationDate.identifier(), - AuthNKeyColumnChangeDate.identifier(), - AuthNKeyColumnResourceOwner.identifier(), - AuthNKeyColumnSequence.identifier(), - AuthNKeyColumnExpiration.identifier(), - AuthNKeyColumnType.identifier(), - countColumn.identifier(), - ).From(authNKeyTable.identifier()). - PlaceholderFormat(sq.Dollar), - func(rows *sql.Rows) (*AuthNKeys, error) { - authNKeys := make([]*AuthNKey, 0) - var count uint64 - for rows.Next() { - authNKey := new(AuthNKey) - err := rows.Scan( - &authNKey.ID, - &authNKey.CreationDate, - &authNKey.ChangeDate, - &authNKey.ResourceOwner, - &authNKey.Sequence, - &authNKey.Expiration, - &authNKey.Type, - &count, - ) - if err != nil { - return nil, err - } - authNKeys = append(authNKeys, authNKey) - } + query := sq.Select( + AuthNKeyColumnID.identifier(), + AuthNKeyColumnAggregateID.identifier(), + AuthNKeyColumnCreationDate.identifier(), + AuthNKeyColumnChangeDate.identifier(), + AuthNKeyColumnResourceOwner.identifier(), + AuthNKeyColumnSequence.identifier(), + AuthNKeyColumnExpiration.identifier(), + AuthNKeyColumnType.identifier(), + countColumn.identifier(), + ).From(authNKeyTable.identifier()). + PlaceholderFormat(sq.Dollar) - if err := rows.Close(); err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Dgfn3", "Errors.Query.CloseRows") + return query, func(rows *sql.Rows) (*AuthNKeys, error) { + authNKeys := make([]*AuthNKey, 0) + var count uint64 + for rows.Next() { + authNKey := new(AuthNKey) + err := rows.Scan( + &authNKey.ID, + &authNKey.AggregateID, + &authNKey.CreationDate, + &authNKey.ChangeDate, + &authNKey.ResourceOwner, + &authNKey.Sequence, + &authNKey.Expiration, + &authNKey.Type, + &count, + ) + if err != nil { + return nil, err } - - return &AuthNKeys{ - AuthNKeys: authNKeys, - SearchResponse: SearchResponse{ - Count: count, - }, - }, nil + authNKeys = append(authNKeys, authNKey) } + + if err := rows.Close(); err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-Dgfn3", "Errors.Query.CloseRows") + } + + return &AuthNKeys{ + AuthNKeys: authNKeys, + SearchResponse: SearchResponse{ + Count: count, + }, + }, nil + } } func prepareAuthNKeyQuery() (sq.SelectBuilder, func(row *sql.Row) (*AuthNKey, error)) { diff --git a/internal/query/authn_key_test.go b/internal/query/authn_key_test.go index c7441f8dae..b7c66cc665 100644 --- a/internal/query/authn_key_test.go +++ b/internal/query/authn_key_test.go @@ -19,6 +19,7 @@ import ( var ( prepareAuthNKeysStmt = `SELECT projections.authn_keys2.id,` + + ` projections.authn_keys2.aggregate_id,` + ` projections.authn_keys2.creation_date,` + ` projections.authn_keys2.change_date,` + ` projections.authn_keys2.resource_owner,` + @@ -29,6 +30,7 @@ var ( ` FROM projections.authn_keys2` prepareAuthNKeysCols = []string{ "id", + "aggregate_id", "creation_date", "change_date", "resource_owner", @@ -120,6 +122,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { [][]driver.Value{ { "id", + "aggId", testNow, testNow, "ro", @@ -137,6 +140,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { AuthNKeys: []*AuthNKey{ { ID: "id", + AggregateID: "aggId", CreationDate: testNow, ChangeDate: testNow, ResourceOwner: "ro", @@ -157,6 +161,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { [][]driver.Value{ { "id-1", + "aggId-1", testNow, testNow, "ro", @@ -166,6 +171,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { }, { "id-2", + "aggId-2", testNow, testNow, "ro", @@ -183,6 +189,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { AuthNKeys: []*AuthNKey{ { ID: "id-1", + AggregateID: "aggId-1", CreationDate: testNow, ChangeDate: testNow, ResourceOwner: "ro", @@ -192,6 +199,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { }, { ID: "id-2", + AggregateID: "aggId-2", CreationDate: testNow, ChangeDate: testNow, ResourceOwner: "ro", diff --git a/internal/query/projection/authn_key.go b/internal/query/projection/authn_key.go index e2229ad332..a287701cfb 100644 --- a/internal/query/projection/authn_key.go +++ b/internal/query/projection/authn_key.go @@ -62,6 +62,9 @@ func (*authNKeyProjection) Init() *old_handler.Check { handler.NewPrimaryKey(AuthNKeyInstanceIDCol, AuthNKeyIDCol), handler.WithIndex(handler.NewIndex("enabled", []string{AuthNKeyEnabledCol})), handler.WithIndex(handler.NewIndex("identifier", []string{AuthNKeyIdentifierCol})), + handler.WithIndex(handler.NewIndex("resource_owner", []string{AuthNKeyResourceOwnerCol})), + handler.WithIndex(handler.NewIndex("creation_date", []string{AuthNKeyCreationDateCol})), + handler.WithIndex(handler.NewIndex("expiration_date", []string{AuthNKeyExpirationCol})), ), ) } diff --git a/internal/query/projection/user_personal_access_token.go b/internal/query/projection/user_personal_access_token.go index 0efb5d6412..610ca9c4e2 100644 --- a/internal/query/projection/user_personal_access_token.go +++ b/internal/query/projection/user_personal_access_token.go @@ -56,6 +56,8 @@ func (*personalAccessTokenProjection) Init() *old_handler.Check { handler.WithIndex(handler.NewIndex("user_id", []string{PersonalAccessTokenColumnUserID})), handler.WithIndex(handler.NewIndex("resource_owner", []string{PersonalAccessTokenColumnResourceOwner})), handler.WithIndex(handler.NewIndex("owner_removed", []string{PersonalAccessTokenColumnOwnerRemoved})), + handler.WithIndex(handler.NewIndex("creation_date", []string{PersonalAccessTokenColumnCreationDate})), + handler.WithIndex(handler.NewIndex("expiration_date", []string{PersonalAccessTokenColumnExpiration})), ), ) } diff --git a/internal/query/user.go b/internal/query/user.go index 3d47847cac..6844982f07 100644 --- a/internal/query/user.go +++ b/internal/query/user.go @@ -132,16 +132,20 @@ func usersCheckPermission(ctx context.Context, users *Users, permissionCheck dom ) } -func userPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, queries *UserSearchQueries) sq.SelectBuilder { +func userPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, filters []SearchQuery) sq.SelectBuilder { + return userPermissionCheckV2WithCustomColumns(ctx, query, enabled, filters, UserResourceOwnerCol, UserIDCol) +} + +func userPermissionCheckV2WithCustomColumns(ctx context.Context, query sq.SelectBuilder, enabled bool, filters []SearchQuery, userResourceOwnerCol, userID Column) sq.SelectBuilder { if !enabled { return query } join, args := PermissionClause( ctx, - UserResourceOwnerCol, + userResourceOwnerCol, domain.PermissionUserRead, - SingleOrgPermissionOption(queries.Queries), - OwnedRowsPermissionOption(UserIDCol), + SingleOrgPermissionOption(filters), + OwnedRowsPermissionOption(userID), ) return query.JoinClause(join, args...) } @@ -637,7 +641,7 @@ func (q *Queries) searchUsers(ctx context.Context, queries *UserSearchQueries, p defer func() { span.EndWithError(err) }() query, scan := prepareUsersQuery() - query = userPermissionCheckV2(ctx, query, permissionCheckV2, queries) + query = userPermissionCheckV2(ctx, query, permissionCheckV2, queries.Queries) stmt, args, err := queries.toQuery(query).Where(sq.Eq{ UserInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), }).ToSql() diff --git a/internal/query/user_personal_access_token.go b/internal/query/user_personal_access_token.go index 8ea33f51a4..61d349961c 100644 --- a/internal/query/user_personal_access_token.go +++ b/internal/query/user_personal_access_token.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "slices" "time" sq "github.com/Masterminds/squirrel" @@ -11,12 +12,21 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" "github.com/zitadel/zitadel/internal/query/projection" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" ) +func patsCheckPermission(ctx context.Context, tokens *PersonalAccessTokens, permissionCheck domain.PermissionCheck) { + tokens.PersonalAccessTokens = slices.DeleteFunc(tokens.PersonalAccessTokens, + func(token *PersonalAccessToken) bool { + return userCheckPermission(ctx, token.ResourceOwner, token.UserID, permissionCheck) != nil + }, + ) +} + var ( personalAccessTokensTable = table{ name: projection.PersonalAccessTokenProjectionTable, @@ -86,7 +96,7 @@ type PersonalAccessTokenSearchQueries struct { Queries []SearchQuery } -func (q *Queries) PersonalAccessTokenByID(ctx context.Context, shouldTriggerBulk bool, id string, withOwnerRemoved bool, queries ...SearchQuery) (pat *PersonalAccessToken, err error) { +func (q *Queries) PersonalAccessTokenByID(ctx context.Context, shouldTriggerBulk bool, id string, queries ...SearchQuery) (pat *PersonalAccessToken, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -102,11 +112,9 @@ func (q *Queries) PersonalAccessTokenByID(ctx context.Context, shouldTriggerBulk query = q.toQuery(query) } eq := sq.Eq{ - PersonalAccessTokenColumnID.identifier(): id, - PersonalAccessTokenColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), - } - if !withOwnerRemoved { - eq[PersonalAccessTokenColumnOwnerRemoved.identifier()] = false + PersonalAccessTokenColumnID.identifier(): id, + PersonalAccessTokenColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + PersonalAccessTokenColumnOwnerRemoved.identifier(): false, } stmt, args, err := query.Where(eq).ToSql() if err != nil { @@ -123,18 +131,34 @@ func (q *Queries) PersonalAccessTokenByID(ctx context.Context, shouldTriggerBulk return pat, nil } -func (q *Queries) SearchPersonalAccessTokens(ctx context.Context, queries *PersonalAccessTokenSearchQueries, withOwnerRemoved bool) (personalAccessTokens *PersonalAccessTokens, err error) { +// SearchPersonalAccessTokens returns personal access token resources. +// If permissionCheck is nil, the PATs are not filtered. +// If permissionCheck is not nil and the PermissionCheckV2 feature flag is false, the returned PATs are filtered in-memory by the given permission check. +// If permissionCheck is not nil and the PermissionCheckV2 feature flag is true, the returned PATs are filtered in the database. +func (q *Queries) SearchPersonalAccessTokens(ctx context.Context, queries *PersonalAccessTokenSearchQueries, permissionCheck domain.PermissionCheck) (authNKeys *PersonalAccessTokens, err error) { + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + keys, err := q.searchPersonalAccessTokens(ctx, queries, permissionCheckV2) + if err != nil { + return nil, err + } + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { + patsCheckPermission(ctx, keys, permissionCheck) + } + return keys, nil +} + +func (q *Queries) searchPersonalAccessTokens(ctx context.Context, queries *PersonalAccessTokenSearchQueries, permissionCheckV2 bool) (personalAccessTokens *PersonalAccessTokens, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() query, scan := preparePersonalAccessTokensQuery() + query = queries.toQuery(query) + query = userPermissionCheckV2WithCustomColumns(ctx, query, permissionCheckV2, queries.Queries, PersonalAccessTokenColumnResourceOwner, PersonalAccessTokenColumnUserID) eq := sq.Eq{ - PersonalAccessTokenColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + PersonalAccessTokenColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + PersonalAccessTokenColumnOwnerRemoved.identifier(): false, } - if !withOwnerRemoved { - eq[PersonalAccessTokenColumnOwnerRemoved.identifier()] = false - } - stmt, args, err := queries.toQuery(query).Where(eq).ToSql() + stmt, args, err := query.Where(eq).ToSql() if err != nil { return nil, zerrors.ThrowInvalidArgument(err, "QUERY-Hjw2w", "Errors.Query.InvalidRequest") } @@ -160,6 +184,18 @@ func NewPersonalAccessTokenUserIDSearchQuery(value string) (SearchQuery, error) return NewTextQuery(PersonalAccessTokenColumnUserID, value, TextEquals) } +func NewPersonalAccessTokenIDQuery(id string) (SearchQuery, error) { + return NewTextQuery(PersonalAccessTokenColumnID, id, TextEquals) +} + +func NewPersonalAccessTokenCreationDateQuery(ts time.Time, compare TimestampComparison) (SearchQuery, error) { + return NewTimestampQuery(PersonalAccessTokenColumnCreationDate, ts, compare) +} + +func NewPersonalAccessTokenExpirationDateDateQuery(ts time.Time, compare TimestampComparison) (SearchQuery, error) { + return NewTimestampQuery(PersonalAccessTokenColumnExpiration, ts, compare) +} + func (r *PersonalAccessTokenSearchQueries) AppendMyResourceOwnerQuery(orgID string) error { query, err := NewPersonalAccessTokenResourceOwnerSearchQuery(orgID) if err != nil { diff --git a/internal/repository/user/machine.go b/internal/repository/user/machine.go index d76290931a..a466f92fe3 100644 --- a/internal/repository/user/machine.go +++ b/internal/repository/user/machine.go @@ -88,10 +88,7 @@ func NewMachineChangedEvent( ctx context.Context, aggregate *eventstore.Aggregate, changes []MachineChanges, -) (*MachineChangedEvent, error) { - if len(changes) == 0 { - return nil, zerrors.ThrowPreconditionFailed(nil, "USER-3M9fs", "Errors.NoChangesFound") - } +) *MachineChangedEvent { changeEvent := &MachineChangedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( ctx, @@ -102,7 +99,7 @@ func NewMachineChangedEvent( for _, change := range changes { change(changeEvent) } - return changeEvent, nil + return changeEvent } type MachineChanges func(event *MachineChangedEvent) diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index 8254b82b45..d58b2eb64a 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -117,6 +117,7 @@ Errors: AlreadyVerified: Телефонът вече е потвърден Empty: Телефонът е празен NotChanged: Телефонът не е сменен + VerifyingRemovalIsNotSupported: Премахването на проверката не се поддържа Address: NotFound: Адресът не е намерен NotChanged: Адресът не е променен diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index bb4172fbff..d248ce4ca7 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: Telefon již ověřen Empty: Telefon je prázdný NotChanged: Telefon nezměněn + VerifyingRemovalIsNotSupported: Ověření odstranění telefonu není podporováno Address: NotFound: Adresa nenalezena NotChanged: Adresa nezměněna diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index a24ce7c933..96edf57456 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: Telefonnummer bereits verifiziert Empty: Telefonnummer ist leer NotChanged: Telefonnummer wurde nicht geändert + VerifyingRemovalIsNotSupported: Verifizieren der Telefonnummer Entfernung wird nicht unterstützt Address: NotFound: Adresse nicht gefunden NotChanged: Adresse wurde nicht geändert diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index e8f2781de1..0f512defe4 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -116,6 +116,7 @@ Errors: AlreadyVerified: Phone already verified Empty: Phone is empty NotChanged: Phone not changed + VerifyingRemovalIsNotSupported: Verifying phone removal is not supported Address: NotFound: Address not found NotChanged: Address not changed diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index b91d055f70..8c901f8ebe 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: El teléfono ya se verificó Empty: El teléfono está vacío NotChanged: El teléfono no ha cambiado + VerifyingRemovalIsNotSupported: La verificación de eliminación no está soportada Address: NotFound: Dirección no encontrada NotChanged: La dirección no ha cambiado diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 98f2bee9a0..2a2a51d7c4 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: Téléphone déjà vérifié Empty: Téléphone est vide NotChanged: Téléphone n'a pas changé + VerifyingRemovalIsNotSupported: La vérification de la suppression n'est pas prise en charge Address: NotFound: Adresse non trouvée NotChanged: L'adresse n'a pas changé diff --git a/internal/static/i18n/hu.yaml b/internal/static/i18n/hu.yaml index 5becd6e606..a4cc908fa2 100644 --- a/internal/static/i18n/hu.yaml +++ b/internal/static/i18n/hu.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: Telefon már ellenőrizve Empty: A telefon mező üres NotChanged: Telefon nem lett megváltoztatva + VerifyingRemovalIsNotSupported: A telefon eltávolításának ellenőrzése nem támogatott Address: NotFound: Cím nem található NotChanged: Cím nem lett megváltoztatva diff --git a/internal/static/i18n/id.yaml b/internal/static/i18n/id.yaml index 0108d7618b..c9187020f7 100644 --- a/internal/static/i18n/id.yaml +++ b/internal/static/i18n/id.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: Telepon sudah diverifikasi Empty: Telepon kosong NotChanged: Telepon tidak berubah + VerifyingRemovalIsNotSupported: Verifikasi penghapusan tidak didukung Address: NotFound: Alamat tidak ditemukan NotChanged: Alamat tidak berubah diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 750c48471a..d1dccef4c7 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: Telefono già verificato Empty: Il telefono è vuoto NotChanged: Telefono non cambiato + VerifyingRemovalIsNotSupported: La rimozione della verifica non è supportata Address: NotFound: Indirizzo non trovato NotChanged: Indirizzo non cambiato diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index fcd7920999..4b0f2ea203 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -116,6 +116,7 @@ Errors: AlreadyVerified: 電話番号はすでに認証済みです Empty: 電話番号が空です NotChanged: 電話番号が変更されていません + VerifyingRemovalIsNotSupported: 電話番号の削除を検証することはできません Address: NotFound: 住所が見つかりません NotChanged: 住所は変更されていません diff --git a/internal/static/i18n/ko.yaml b/internal/static/i18n/ko.yaml index d83af62235..2c87aa1f97 100644 --- a/internal/static/i18n/ko.yaml +++ b/internal/static/i18n/ko.yaml @@ -116,6 +116,7 @@ Errors: AlreadyVerified: 전화번호가 이미 인증되었습니다 Empty: 전화번호가 비어 있습니다 NotChanged: 전화번호가 변경되지 않았습니다 + VerifyingRemovalIsNotSupported: 전화번호 제거를 확인하는 것은 지원되지 않습니다 Address: NotFound: 주소를 찾을 수 없습니다 NotChanged: 주소가 변경되지 않았습니다 diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index 7126925279..64ae87a618 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: Телефонскиот број веќе е верифициран Empty: Телефонскиот број е празен NotChanged: Телефонскиот број не е променет + VerifyingRemovalIsNotSupported: Отстранувањето на верификацијата не е поддржано Address: NotFound: Адресата не е пронајдена NotChanged: Адресата не е променета diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index a398e4b770..dc9fd83721 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: Telefoon is al geverifieerd Empty: Telefoon is leeg NotChanged: Telefoon niet veranderd + VerifyingRemovalIsNotSupported: Verwijderen van verificatie is niet ondersteund Address: NotFound: Adres niet gevonden NotChanged: Adres niet veranderd diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 049a189930..4952345510 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: Numer telefonu już zweryfikowany Empty: Numer telefonu jest pusty NotChanged: Numer telefonu nie zmieniony + VerifyingRemovalIsNotSupported: Usunięcie weryfikacji nie jest obsługiwane Address: NotFound: Adres nie znaleziony NotChanged: Adres nie zmieniony diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index 09a5fc02c5..e5fc785d0c 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: O telefone já foi verificado Empty: O telefone está vazio NotChanged: Telefone não alterado + VerifyingRemovalIsNotSupported: Remoção de verificação não suportada Address: NotFound: Endereço não encontrado NotChanged: Endereço não alterado diff --git a/internal/static/i18n/ro.yaml b/internal/static/i18n/ro.yaml index 9010e57032..ece4680de6 100644 --- a/internal/static/i18n/ro.yaml +++ b/internal/static/i18n/ro.yaml @@ -116,6 +116,7 @@ Errors: AlreadyVerified: Numărul de telefon este deja verificat Empty: Numărul de telefon este gol NotChanged: Numărul de telefon nu a fost schimbat + VerifyingRemovalIsNotSupported: Verificarea eliminării nu este acceptată Address: NotFound: Adresa nu a fost găsită NotChanged: Adresa nu a fost schimbată diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index 38b2847637..a2efd25322 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -116,6 +116,7 @@ Errors: AlreadyVerified: Телефон уже подтверждён Empty: Телефон пуст NotChanged: Телефон не менялся + VerifyingRemovalIsNotSupported: Удаление телефона не поддерживается Address: NotFound: Адрес не найден NotChanged: Адрес не изменён diff --git a/internal/static/i18n/sv.yaml b/internal/static/i18n/sv.yaml index ed4b863886..be40ceba3c 100644 --- a/internal/static/i18n/sv.yaml +++ b/internal/static/i18n/sv.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: Mobilnr redan verifierad Empty: Mobilnr är tom NotChanged: Mobilnr ändrades inte + VerifyingRemovalIsNotSupported: Verifiering av borttagning stöds inte Address: NotFound: Adress hittades inte NotChanged: Adress ändrades inte diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 03aa168a50..930fcaddae 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: 手机号码已经验证 Empty: 电话号码是空的 NotChanged: 电话号码没有改变 + VerifyingRemovalIsNotSupported: 验证手机号码删除不受支持 Address: NotFound: 找不到地址 NotChanged: 地址没有改变 diff --git a/proto/buf.yaml b/proto/buf.yaml index 31bc7b4ccc..abe35b3055 100644 --- a/proto/buf.yaml +++ b/proto/buf.yaml @@ -40,4 +40,4 @@ lint: - zitadel/system.proto - zitadel/text.proto - zitadel/user.proto - - zitadel/v1.proto \ No newline at end of file + - zitadel/v1.proto diff --git a/proto/zitadel/filter/v2/filter.proto b/proto/zitadel/filter/v2/filter.proto new file mode 100644 index 0000000000..3817324d31 --- /dev/null +++ b/proto/zitadel/filter/v2/filter.proto @@ -0,0 +1,96 @@ +syntax = "proto3"; + +package zitadel.filter.v2; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/filter/v2;filter"; + +import "google/protobuf/timestamp.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +enum TextFilterMethod { + TEXT_FILTER_METHOD_EQUALS = 0; + TEXT_FILTER_METHOD_EQUALS_IGNORE_CASE = 1; + TEXT_FILTER_METHOD_STARTS_WITH = 2; + TEXT_FILTER_METHOD_STARTS_WITH_IGNORE_CASE = 3; + TEXT_FILTER_METHOD_CONTAINS = 4; + TEXT_FILTER_METHOD_CONTAINS_IGNORE_CASE = 5; + TEXT_FILTER_METHOD_ENDS_WITH = 6; + TEXT_FILTER_METHOD_ENDS_WITH_IGNORE_CASE = 7; +} + +enum ListFilterMethod { + LIST_FILTER_METHOD_IN = 0; +} + +enum TimestampFilterMethod { + TIMESTAMP_FILTER_METHOD_EQUALS = 0; + TIMESTAMP_FILTER_METHOD_AFTER = 1; + TIMESTAMP_FILTER_METHOD_AFTER_OR_EQUALS = 2; + TIMESTAMP_FILTER_METHOD_BEFORE = 3; + TIMESTAMP_FILTER_METHOD_BEFORE_OR_EQUALS = 4; +} + +message PaginationRequest { + // Starting point for retrieval, in combination of offset used to query a set list of objects. + uint64 offset = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "0"; + } + ]; + // limit is the maximum amount of objects returned. The default is set to 100 + // with a maximum of 1000 in the runtime configuration. + // If the limit exceeds the maximum configured ZITADEL will throw an error. + // If no limit is present the default is taken. + uint32 limit = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "10"; + } + ]; + // Asc is the sorting order. If true the list is sorted ascending, if false + // the list is sorted descending. The default is descending. + bool asc = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "false"; + } + ]; +} + +message PaginationResponse { + // Absolute number of objects matching the query, regardless of applied limit. + uint64 total_result = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "100"; + } + ]; + // Applied limit from query, defines maximum amount of objects per request, to compare if all objects are returned. + uint64 applied_limit = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "100"; + } + ]; +} + +message IDFilter { + // Only return resources that belong to this id. + string id = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"69629023906488337\""; + } + ]; +} + +message TimestampFilter { + // Filter resources by timestamp. + google.protobuf.Timestamp timestamp = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // Defines the condition (e.g., equals, before, after) that the timestamp of the retrieved resources should match. + TimestampFilterMethod method = 2 [ + (validate.rules).enum.defined_only = true + ]; +} diff --git a/proto/zitadel/filter/v2beta/filter.proto b/proto/zitadel/filter/v2beta/filter.proto index 6aae583cde..2265fa4125 100644 --- a/proto/zitadel/filter/v2beta/filter.proto +++ b/proto/zitadel/filter/v2beta/filter.proto @@ -6,6 +6,7 @@ option go_package = "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta;filter"; import "google/protobuf/timestamp.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; enum TextFilterMethod { TEXT_FILTER_METHOD_EQUALS = 0; @@ -56,4 +57,37 @@ message PaginationResponse { example: "\"100\""; } ]; -} \ No newline at end of file +} + +message IDFilter { + // Only return resources that belong to this id. + string id = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"69629023906488337\""; + } + ]; +} + +message TimestampFilter { + // Filter resources by timestamp. + google.protobuf.Timestamp timestamp = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // Defines the condition (e.g., equals, before, after) that the timestamp of the retrieved resources should match. + TimestampFilterMethod method = 2 [ + (validate.rules).enum.defined_only = true + ]; +} + +message InIDsFilter { + // Defines the ids to query for. + repeated string ids = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"69629023906488334\",\"69622366012355662\"]"; + } + ]; +} diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 34a8384d39..8cd0b22759 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -432,7 +432,11 @@ service ManagementService { }; } - // Deprecated: use ImportHumanUser + // Create User (Human) + // + // Deprecated: use [ImportHumanUser](apis/resources/mgmt/management-service-import-human-user.api.mdx) instead. + // + // Create a new user with the type human. The newly created user will get an initialization email if either the email address is not marked as verified or no password is set. If a password is set the user will not be requested to set a new one on the first login. rpc AddHumanUser(AddHumanUserRequest) returns (AddHumanUserResponse) { option (google.api.http) = { post: "/users/human" @@ -444,10 +448,8 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Deprecated: Create User (Human)"; - description: "Create a new user with the type human. The newly created user will get an initialization email if either the email address is not marked as verified or no password is set. If a password is set the user will not be requested to set a new one on the first login.\n\nDeprecated: use ImportHumanUser" - tags: "Users"; deprecated: true; + tags: "Users"; parameters: { headers: { name: "x-zitadel-orgid"; @@ -459,7 +461,11 @@ service ManagementService { }; } - // Deprecated: please use user service v2 AddHumanUser + // Create/Import User (Human) + // + // Deprecated: use [UpdateHumanUser](apis/resources/user_service_v2/user-service-update-human-user.api.mdx) instead. + // + // Create/import a new user with the type human. The newly created user will get an initialization email if either the email address is not marked as verified or no password is set. If a password is set the user will not be requested to set a new one on the first login. rpc ImportHumanUser(ImportHumanUserRequest) returns (ImportHumanUserResponse) { option (google.api.http) = { post: "/users/human/_import" @@ -471,11 +477,9 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Create/Import User (Human)"; - description: "Create/import a new user with the type human. The newly created user will get an initialization email if either the email address is not marked as verified or no password is set. If a password is set the user will not be requested to set a new one on the first login.\n\nDeprecated: please use user service v2 [AddHumanUser](apis/resources/user_service_v2/user-service-add-human-user.api.mdx)" + deprecated: true; tags: "Users"; tags: "User Human" - deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -487,6 +491,11 @@ service ManagementService { }; } + // Create User (Machine) + // + // Deprecated: use [user service v2 CreateUser](apis/resources/user_service_v2/user-service-create-user.api.mdx) to create a user of type machine instead. + // + // Create a new user with the type machine for your API, service or device. These users are used for non-interactive authentication flows. rpc AddMachineUser(AddMachineUserRequest) returns (AddMachineUserResponse) { option (google.api.http) = { post: "/users/machine" @@ -498,8 +507,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Create User (Machine)"; - description: "Create a new user with the type machine for your API, service or device. These users are used for non-interactive authentication flows." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -683,7 +691,11 @@ service ManagementService { }; } - // Deprecated: please use user service v2 UpdateHumanUser + // Change user name + // + // Deprecated: use [user service v2 UpdateUser](apis/resources/user_service_v2/user-service-update-user.api.mdx) instead. + // + // Change the username of the user. Be aware that the user has to log in with the newly added username afterward rpc UpdateUserName(UpdateUserNameRequest) returns (UpdateUserNameResponse) { option (google.api.http) = { put: "/users/{user_id}/username" @@ -695,8 +707,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Change user name"; - description: "Change the username of the user. Be aware that the user has to log in with the newly added username afterward.\n\nDeprecated: please use user service v2 UpdateHumanUser" tags: "Users"; deprecated: true; responses: { @@ -903,7 +913,11 @@ service ManagementService { }; } - // Deprecated: please use user service v2 UpdateHumanUser + // Update User Profile (Human) + // + // Deprecated: use [user service v2 UpdateHumanUser](apis/resources/user_service_v2/user-service-update-human-user.api.mdx) instead. + // + // Update the profile information from a user. The profile includes basic information like first_name and last_name. rpc UpdateHumanProfile(UpdateHumanProfileRequest) returns (UpdateHumanProfileResponse) { option (google.api.http) = { put: "/users/{user_id}/profile" @@ -915,11 +929,9 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Update User Profile (Human)"; - description: "Update the profile information from a user. The profile includes basic information like first_name and last_name.\n\nDeprecated: please use user service v2 UpdateHumanUser" + deprecated: true; tags: "Users"; tags: "User Human"; - deprecated: true; responses: { key: "200" value: { @@ -970,7 +982,11 @@ service ManagementService { }; } - // Deprecated: please use user service v2 SetEmail + // Update User Email (Human) + // + // Deprecated: use [user service v2 SetEmail](apis/resources/user_service_v2/user-service-set-email.api.mdx) instead. + // + // Change the email address of a user. If the state is set to not verified, the user will get a verification email. rpc UpdateHumanEmail(UpdateHumanEmailRequest) returns (UpdateHumanEmailResponse) { option (google.api.http) = { put: "/users/{user_id}/email" @@ -982,8 +998,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Update User Email (Human)"; - description: "Change the email address of a user. If the state is set to not verified, the user will get a verification email.\n\nDeprecated: please use user service v2 SetEmail" tags: "Users"; tags: "User Human"; deprecated: true; @@ -1039,7 +1053,11 @@ service ManagementService { }; } - // Deprecated: please use user service v2 ResendEmailCode + // Resend User Email Verification + // + // Deprecated: use [user service v2 ResendEmailCode](apis/resources/user_service_v2/user-service-resend-email-code.api.mdx) instead. + // + // Resend the email verification notification to the given email address of the user. rpc ResendHumanEmailVerification(ResendHumanEmailVerificationRequest) returns (ResendHumanEmailVerificationResponse) { option (google.api.http) = { post: "/users/{user_id}/email/_resend_verification" @@ -1051,8 +1069,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Resend User Email Verification"; - description: "Resend the email verification notification to the given email address of the user.\n\nDeprecated: please use user service v2 ResendEmailCode" tags: "Users"; tags: "User Human"; deprecated: true; @@ -1106,7 +1122,11 @@ service ManagementService { }; } - // Deprecated: please use user service v2 SetPhone + // Update User Phone (Human) + // + // Deprecated: use [user service v2 SetPhone](apis/resources/user_service_v2/user-service-update-user.api.mdx) instead. + // + // Change the phone number of a user. If the state is set to not verified, the user will get an SMS to verify (if a notification provider is configured). The phone number is only for informational purposes and to send messages, not for Authentication (2FA). rpc UpdateHumanPhone(UpdateHumanPhoneRequest) returns (UpdateHumanPhoneResponse) { option (google.api.http) = { put: "/users/{user_id}/phone" @@ -1118,8 +1138,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Update User Phone (Human)"; - description: "Change the phone number of a user. If the state is set to not verified, the user will get an SMS to verify (if a notification provider is configured). The phone number is only for informational purposes and to send messages, not for Authentication (2FA).\n\nDeprecated: please use user service v2 SetPhone" tags: "Users"; tags: "User Human"; deprecated: true; @@ -1140,7 +1158,11 @@ service ManagementService { }; } - // Deprecated: please use user service v2 SetPhone + // Remove User Phone (Human) + // + // Deprecated: use user service v2 [user service v2 SetPhone](apis/resources/user_service_v2/user-service-set-phone.api.mdx) instead. + // + // Remove the configured phone number of a user. rpc RemoveHumanPhone(RemoveHumanPhoneRequest) returns (RemoveHumanPhoneResponse) { option (google.api.http) = { delete: "/users/{user_id}/phone" @@ -1151,8 +1173,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Remove User Phone (Human)"; - description: "Remove the configured phone number of a user.\n\nDeprecated: please use user service v2 SetPhone" tags: "Users"; tags: "User Human"; deprecated: true; @@ -1173,7 +1193,11 @@ service ManagementService { }; } - // Deprecated: please use user service v2 ResendPhoneCode + // Resend User Phone Verification + // + // Deprecated: use user service v2 [user service v2 ResendPhoneCode](apis/resources/user_service_v2/user-service-resend-phone-code.api.mdx) instead. + // + // Resend the notification for the verification of the phone number, to the number stored on the user. rpc ResendHumanPhoneVerification(ResendHumanPhoneVerificationRequest) returns (ResendHumanPhoneVerificationResponse) { option (google.api.http) = { post: "/users/{user_id}/phone/_resend_verification" @@ -1185,8 +1209,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Resend User Phone Verification"; - description: "Resend the notification for the verification of the phone number, to the number stored on the user.\n\nDeprecated: please use user service v2 ResendPhoneCode" tags: "Users"; tags: "User Human"; deprecated: true; @@ -1238,7 +1260,9 @@ service ManagementService { }; } - // Deprecated: please use user service v2 SetPassword + // Set Human Initial Password + // + // Deprecated: use [user service v2 SetPassword](apis/resources/user_service_v2/user-service-set-password.api.mdx) instead. rpc SetHumanInitialPassword(SetHumanInitialPasswordRequest) returns (SetHumanInitialPasswordResponse) { option (google.api.http) = { post: "/users/{user_id}/password/_initialize" @@ -1252,7 +1276,6 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Users"; tags: "User Human"; - summary: "Set Human Initial Password\n\nDeprecated: please use user service v2 SetPassword"; deprecated: true; parameters: { headers: { @@ -1265,7 +1288,9 @@ service ManagementService { }; } - // Deprecated: please use user service v2 SetPassword + // Set User Password + // + // Deprecated: use [user service v2 SetPassword](apis/resources/user_service_v2/user-service-set-password.api.mdx) instead. rpc SetHumanPassword(SetHumanPasswordRequest) returns (SetHumanPasswordResponse) { option (google.api.http) = { post: "/users/{user_id}/password" @@ -1277,8 +1302,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Set User Password"; - description: "Set a new password for a user. Per default, the user has to change the password on the next login. You can set no_change_required to true, to avoid the change on the next login.\n\nDeprecated: please use user service v2 SetPassword" tags: "Users"; tags: "User Human"; deprecated: true; @@ -1299,7 +1322,11 @@ service ManagementService { }; } - // Deprecated: please use user service v2 PasswordReset + // Send Reset Password Notification + // + // Deprecated: use [user service v2 PasswordReset](apis/resources/user_service_v2/user-service-password-reset.api.mdx) instead. + // + // The user will receive an email with a link to change the password. rpc SendHumanResetPasswordNotification(SendHumanResetPasswordNotificationRequest) returns (SendHumanResetPasswordNotificationResponse) { option (google.api.http) = { post: "/users/{user_id}/password/_reset" @@ -1311,8 +1338,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Send Reset Password Notification"; - description: "The user will receive an email with a link to change the password.\n\nDeprecated: please use user service v2 PasswordReset" tags: "Users"; tags: "User Human"; deprecated: true; @@ -1629,6 +1654,11 @@ service ManagementService { }; } + // Update Machine User + // + // Deprecated: use [user service v2 UpdateUser](apis/resources/user_service_v2/user-service-update-user.api.mdx) to update a user of type machine instead. + // + // Change a service account/machine user. It is used for accounts with non-interactive authentication possibilities. rpc UpdateMachine(UpdateMachineRequest) returns (UpdateMachineResponse) { option (google.api.http) = { put: "/users/{user_id}/machine" @@ -1640,8 +1670,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Update Machine User"; - description: "Change a service account/machine user. It is used for accounts with non-interactive authentication possibilities." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1661,6 +1690,11 @@ service ManagementService { }; } + // Create Secret for Machine User + // + // Deprecated: use [user service v2 AddSecret](apis/resources/user_service_v2/user-service-add-secret.api.mdx) instead. + // + // Create a new secret for a machine user/service account. It is used to authenticate the user (client credential grant). rpc GenerateMachineSecret(GenerateMachineSecretRequest) returns (GenerateMachineSecretResponse) { option (google.api.http) = { put: "/users/{user_id}/secret" @@ -1672,8 +1706,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Create Secret for Machine User"; - description: "Create a new secret for a machine user/service account. It is used to authenticate the user (client credential grant)." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1693,6 +1726,11 @@ service ManagementService { }; } + // Delete Secret of Machine User + // + // Deprecated: use [user service v2 RemoveSecret](apis/resources/user_service_v2/user-service-remove-secret.api.mdx) instead. + // + // Delete a secret of a machine user/service account. The user will not be able to authenticate with the secret afterward. rpc RemoveMachineSecret(RemoveMachineSecretRequest) returns (RemoveMachineSecretResponse) { option (google.api.http) = { delete: "/users/{user_id}/secret" @@ -1703,8 +1741,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Delete Secret of Machine User"; - description: "Delete a secret of a machine user/service account. The user will not be able to authenticate with the secret afterward." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1724,6 +1761,11 @@ service ManagementService { }; } + // Get Machine user Key By ID + // + // Deprecated: use [user service v2 ListKeys](apis/resources/user_service_v2/user-service-list-keys.api.mdx) instead. + // + // Get a specific Key of a machine user by its id. Machine keys are used to authenticate with jwt profile authentication. rpc GetMachineKeyByIDs(GetMachineKeyByIDsRequest) returns (GetMachineKeyByIDsResponse) { option (google.api.http) = { get: "/users/{user_id}/keys/{key_id}" @@ -1734,8 +1776,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Get Machine user Key By ID"; - description: "Get a specific Key of a machine user by its id. Machine keys are used to authenticate with jwt profile authentication." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1755,6 +1796,11 @@ service ManagementService { }; } + // Get Machine user Key By ID + // + // Deprecated: use [user service v2 ListKeys](apis/resources/user_service_v2/user-service-list-keys.api.mdx) instead. + // + // Get the list of keys of a machine user. Machine keys are used to authenticate with jwt profile authentication. rpc ListMachineKeys(ListMachineKeysRequest) returns (ListMachineKeysResponse) { option (google.api.http) = { post: "/users/{user_id}/keys/_search" @@ -1766,8 +1812,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Get Machine user Key By ID"; - description: "Get the list of keys of a machine user. Machine keys are used to authenticate with jwt profile authentication." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1787,6 +1832,14 @@ service ManagementService { }; } + // Create Key for machine user + // + // Deprecated: use [user service v2 AddKey](apis/resources/user_service_v2/user-service-add-key.api.mdx) instead. + // + // If a public key is not supplied, a new key is generated and will be returned in the response. + // Make sure to store the returned key. + // If an RSA public key is supplied, the private key is omitted from the response. + // Machine keys are used to authenticate with jwt profile. rpc AddMachineKey(AddMachineKeyRequest) returns (AddMachineKeyResponse) { option (google.api.http) = { post: "/users/{user_id}/keys" @@ -1798,8 +1851,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Create Key for machine user"; - description: "If a public key is not supplied, a new key is generated and will be returned in the response. Make sure to store the returned key. If an RSA public key is supplied, the private key is omitted from the response. Machine keys are used to authenticate with jwt profile." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1819,6 +1871,12 @@ service ManagementService { }; } + // Delete Key for machine user + // + // Deprecated: use [user service v2 RemoveKey](apis/resources/user_service_v2/user-service-remove-key.api.mdx) instead. + // + // Delete a specific key from a user. + // The user will not be able to authenticate with that key afterward. rpc RemoveMachineKey(RemoveMachineKeyRequest) returns (RemoveMachineKeyResponse) { option (google.api.http) = { delete: "/users/{user_id}/keys/{key_id}" @@ -1829,8 +1887,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Delete Key for machine user"; - description: "Delete a specific key from a user. The user will not be able to authenticate with that key afterward." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1850,6 +1907,11 @@ service ManagementService { }; } + // Get Personal-Access-Token (PAT) by ID + // + // Deprecated: use [user service v2 ListPersonalAccessTokens](apis/resources/user_service_v2/user-service-list-personal-access-tokens.api.mdx) instead. + // + // Returns the PAT for a user, currently only available for machine users/service accounts. PATs are ready-to-use tokens and can be sent directly in the authentication header. rpc GetPersonalAccessTokenByIDs(GetPersonalAccessTokenByIDsRequest) returns (GetPersonalAccessTokenByIDsResponse) { option (google.api.http) = { get: "/users/{user_id}/pats/{token_id}" @@ -1860,8 +1922,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Get a Personal-Access-Token (PAT) by ID"; - description: "Returns the PAT for a user, currently only available for machine users/service accounts. PATs are ready-to-use tokens and can be sent directly in the authentication header." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1881,6 +1942,11 @@ service ManagementService { }; } + // List Personal-Access-Tokens (PATs) + // + // Deprecated: use [user service v2 ListPersonalAccessTokens](apis/resources/user_service_v2/user-service-list-personal-access-tokens.api.mdx) instead. + // + // Returns a list of PATs for a user, currently only available for machine users/service accounts. PATs are ready-to-use tokens and can be sent directly in the authentication header. rpc ListPersonalAccessTokens(ListPersonalAccessTokensRequest) returns (ListPersonalAccessTokensResponse) { option (google.api.http) = { post: "/users/{user_id}/pats/_search" @@ -1892,8 +1958,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Get a Personal-Access-Token (PAT) by ID"; - description: "Returns a list of PATs for a user, currently only available for machine users/service accounts. PATs are ready-to-use tokens and can be sent directly in the authentication header." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1913,6 +1978,13 @@ service ManagementService { }; } + // Create a Personal-Access-Token (PAT) + // + // Deprecated: use [user service v2 AddPersonalAccessToken](apis/resources/user_service_v2/user-service-add-personal-access-token.api.mdx) instead. + // + // Generates a new PAT for the user. Currently only available for machine users. + // The token will be returned in the response, make sure to store it. + // PATs are ready-to-use tokens and can be sent directly in the authentication header. rpc AddPersonalAccessToken(AddPersonalAccessTokenRequest) returns (AddPersonalAccessTokenResponse) { option (google.api.http) = { post: "/users/{user_id}/pats" @@ -1924,8 +1996,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Create a Personal-Access-Token (PAT)"; - description: "Generates a new PAT for the user. Currently only available for machine users. The token will be returned in the response, make sure to store it. PATs are ready-to-use tokens and can be sent directly in the authentication header." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1945,6 +2016,11 @@ service ManagementService { }; } + // Remove a Personal-Access-Token (PAT) by ID + // + // Deprecated: use [user service v2 RemovePersonalAccessToken](apis/resources/user_service_v2/user-service-remove-personal-access-token.api.mdx) instead. + // + // Delete a PAT from a user. Afterward, the user will not be able to authenticate with that token anymore. rpc RemovePersonalAccessToken(RemovePersonalAccessTokenRequest) returns (RemovePersonalAccessTokenResponse) { option (google.api.http) = { delete: "/users/{user_id}/pats/{token_id}" @@ -1955,8 +2031,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Get a Personal-Access-Token (PAT) by ID"; - description: "Delete a PAT from a user. Afterward, the user will not be able to authenticate with that token anymore." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -2003,7 +2078,7 @@ service ManagementService { }; } - // Deprecated: please use user service v2 RemoveLinkedIDP + // Deprecated: please use [user service v2 RemoveIDPLink](apis/resources/user_service_v2/user-service-remove-idp-link.api.mdx) rpc RemoveHumanLinkedIDP(RemoveHumanLinkedIDPRequest) returns (RemoveHumanLinkedIDPResponse) { option (google.api.http) = { delete: "/users/{user_id}/idps/{idp_id}/{linked_user_id}" diff --git a/proto/zitadel/project/v2beta/query.proto b/proto/zitadel/project/v2beta/query.proto index f328b65189..9bfde662a3 100644 --- a/proto/zitadel/project/v2beta/query.proto +++ b/proto/zitadel/project/v2beta/query.proto @@ -185,10 +185,10 @@ message ProjectSearchFilter { option (validate.required) = true; ProjectNameFilter project_name_filter = 1; - InProjectIDsFilter in_project_ids_filter = 2; - ProjectResourceOwnerFilter project_resource_owner_filter = 3; - ProjectGrantResourceOwnerFilter project_grant_resource_owner_filter = 4; - ProjectOrganizationIDFilter project_organization_id_filter = 5; + zitadel.filter.v2beta.InIDsFilter in_project_ids_filter = 2; + zitadel.filter.v2beta.IDFilter project_resource_owner_filter = 3; + zitadel.filter.v2beta.IDFilter project_grant_resource_owner_filter = 4; + zitadel.filter.v2beta.IDFilter project_organization_id_filter = 5; } } @@ -210,68 +210,18 @@ message ProjectNameFilter { ]; } -message InProjectIDsFilter { - // Defines the ids to query for. - repeated string project_ids = 1 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "the ids of the projects to include" - example: "[\"69629023906488334\",\"69622366012355662\"]"; - } - ]; -} - -message ProjectResourceOwnerFilter { - // Defines the ID of organization the project belongs to query for. - string project_resource_owner = 1 [ - (validate.rules).string = {max_len: 200}, - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "\"69629023906488334\"" - } - ]; -} - -message ProjectGrantResourceOwnerFilter { - // Defines the ID of organization the project grant belongs to query for. - string project_grant_resource_owner = 1 [ - (validate.rules).string = {max_len: 200}, - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "\"69629023906488334\"" - } - ]; -} - -message ProjectOrganizationIDFilter { - // Defines the ID of organization the project and granted project belong to query for. - string project_organization_id = 1 [ - (validate.rules).string = {max_len: 200}, - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "\"69629023906488334\"" - } - ]; -} - message ProjectGrantSearchFilter { oneof filter { option (validate.required) = true; ProjectNameFilter project_name_filter = 1; ProjectRoleKeyFilter role_key_filter = 2; - InProjectIDsFilter in_project_ids_filter = 3; - ProjectResourceOwnerFilter project_resource_owner_filter = 4; - ProjectGrantResourceOwnerFilter project_grant_resource_owner_filter = 5; + zitadel.filter.v2beta.InIDsFilter in_project_ids_filter = 3; + zitadel.filter.v2beta.IDFilter project_resource_owner_filter = 4; + zitadel.filter.v2beta.IDFilter project_grant_resource_owner_filter = 5; } } -message GrantedOrganizationIDFilter { - // Defines the ID of organization the project is granted to query for. - string granted_organization_id = 1 [ - (validate.rules).string = {max_len: 200}, - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "\"69629023906488334\"" - } - ]; -} - message ProjectRole { // ID of the project. string project_id = 1 [ @@ -344,4 +294,4 @@ message ProjectRoleDisplayNameFilter { zitadel.filter.v2beta.TextFilterMethod method = 2 [ (validate.rules).enum.defined_only = true ]; -} \ No newline at end of file +} diff --git a/proto/zitadel/user/v2/email.proto b/proto/zitadel/user/v2/email.proto index e962707fcf..eb807206a7 100644 --- a/proto/zitadel/user/v2/email.proto +++ b/proto/zitadel/user/v2/email.proto @@ -19,7 +19,7 @@ message SetHumanEmail { example: "\"mini@mouse.com\""; } ]; - // if no verification is specified, an email is sent with the default url + // If no verification is specified, an email is sent with the default url oneof verification { SendEmailVerificationCode send_code = 2; ReturnEmailVerificationCode return_code = 3; diff --git a/proto/zitadel/user/v2/key.proto b/proto/zitadel/user/v2/key.proto new file mode 100644 index 0000000000..ffa83c714e --- /dev/null +++ b/proto/zitadel/user/v2/key.proto @@ -0,0 +1,69 @@ +syntax = "proto3"; + +package zitadel.user.v2; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v2;user"; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "google/protobuf/timestamp.proto"; +import "validate/validate.proto"; +import "zitadel/filter/v2/filter.proto"; + +message Key { + // The timestamp of the key creation. + google.protobuf.Timestamp creation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The timestamp of the last change of the key. + google.protobuf.Timestamp change_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The unique identifier of the key. + string id = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The unique identifier of the user the key belongs to. + string user_id = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The unique identifier of the organization the key belongs to. + string organization_id = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The keys expiration date. + google.protobuf.Timestamp expiration_date = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message KeysSearchFilter { + oneof filter { + option (validate.required) = true; + zitadel.filter.v2.IDFilter key_id_filter = 1; + zitadel.filter.v2.IDFilter user_id_filter = 2; + zitadel.filter.v2.IDFilter organization_id_filter = 3; + zitadel.filter.v2.TimestampFilter created_date_filter = 4; + zitadel.filter.v2.TimestampFilter expiration_date_filter = 5; + } +} + +enum KeyFieldName { + KEY_FIELD_NAME_UNSPECIFIED = 0; + KEY_FIELD_NAME_CREATED_DATE = 1; + KEY_FIELD_NAME_ID = 2; + KEY_FIELD_NAME_USER_ID = 3; + KEY_FIELD_NAME_ORGANIZATION_ID = 4; + KEY_FIELD_NAME_KEY_EXPIRATION_DATE = 5; +} diff --git a/proto/zitadel/user/v2/pat.proto b/proto/zitadel/user/v2/pat.proto new file mode 100644 index 0000000000..1d24c4c496 --- /dev/null +++ b/proto/zitadel/user/v2/pat.proto @@ -0,0 +1,70 @@ +syntax = "proto3"; + +package zitadel.user.v2; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v2;user"; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "google/protobuf/timestamp.proto"; +import "validate/validate.proto"; +import "zitadel/filter/v2/filter.proto"; + +message PersonalAccessToken { + // The timestamp of the personal access token creation. + google.protobuf.Timestamp creation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The timestamp of the last change of the personal access token. + google.protobuf.Timestamp change_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The unique identifier of the personal access token. + string id = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The unique identifier of the user the personal access token belongs to. + string user_id = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The unique identifier of the organization the personal access token belongs to. + string organization_id = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The personal access tokens expiration date. + google.protobuf.Timestamp expiration_date = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message PersonalAccessTokensSearchFilter { + oneof filter { + option (validate.required) = true; + zitadel.filter.v2.IDFilter token_id_filter = 1; + zitadel.filter.v2.IDFilter user_id_filter = 2; + zitadel.filter.v2.IDFilter organization_id_filter = 3; + zitadel.filter.v2.TimestampFilter created_date_filter = 4; + zitadel.filter.v2.TimestampFilter expiration_date_filter = 5; + } +} + +enum PersonalAccessTokenFieldName { + PERSONAL_ACCESS_TOKEN_FIELD_NAME_UNSPECIFIED = 0; + PERSONAL_ACCESS_TOKEN_FIELD_NAME_CREATED_DATE = 1; + PERSONAL_ACCESS_TOKEN_FIELD_NAME_ID = 2; + PERSONAL_ACCESS_TOKEN_FIELD_NAME_USER_ID = 3; + PERSONAL_ACCESS_TOKEN_FIELD_NAME_ORGANIZATION_ID = 4; + PERSONAL_ACCESS_TOKEN_FIELD_NAME_EXPIRATION_DATE = 5; +} + diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index 44d25c07b3..3fc81836d6 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -2,6 +2,14 @@ syntax = "proto3"; package zitadel.user.v2; +import "google/protobuf/timestamp.proto"; +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + import "zitadel/object/v2/object.proto"; import "zitadel/protoc_gen_zitadel/v2/options.proto"; import "zitadel/user/v2/auth.proto"; @@ -10,13 +18,10 @@ import "zitadel/user/v2/phone.proto"; import "zitadel/user/v2/idp.proto"; import "zitadel/user/v2/password.proto"; import "zitadel/user/v2/user.proto"; +import "zitadel/user/v2/key.proto"; +import "zitadel/user/v2/pat.proto"; import "zitadel/user/v2/query.proto"; -import "google/api/annotations.proto"; -import "google/api/field_behavior.proto"; -import "google/protobuf/duration.proto"; -import "google/protobuf/struct.proto"; -import "protoc-gen-openapiv2/options/annotations.proto"; -import "validate/validate.proto"; +import "zitadel/filter/v2/filter.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v2;user"; @@ -85,9 +90,9 @@ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { } } responses: { - key: "403"; + key: "400"; value: { - description: "Returned when the user does not have permission to access the resource."; + description: "The request is malformed."; schema: { json_schema: { ref: "#/definitions/rpcStatus"; @@ -96,9 +101,20 @@ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { } } responses: { - key: "404"; + key: "401"; value: { - description: "Returned when the resource does not exist."; + description: "Returned when the user is not authenticated."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; schema: { json_schema: { ref: "#/definitions/rpcStatus"; @@ -110,8 +126,51 @@ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { service UserService { + // Create a User + // + // Create a new human or machine user in the specified organization. + // + // Required permission: + // - user.write + rpc CreateUser (CreateUserRequest) returns (CreateUserResponse) { + option (google.api.http) = { + // The /new path segment does not follow Zitadels API design. + // The only reason why it is used here is to avoid a conflict with the ListUsers endpoint, which already handles POST /v2/users. + post: "/v2/users/new" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "OK"; + } + }; + responses: { + key: "409" + value: { + description: "The user already exists."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + // Create a new human user // + // Deprecated: Use [CreateUser](apis/resources/user_service_v2/user-service-create-user.api.mdx) to create a new user of type human instead. + // // Create/import a new user with the type human. The newly created user will get a verification email if either the email address is not marked as verified and you did not request the verification to be returned. rpc AddHumanUser (AddHumanUserRequest) returns (AddHumanUserResponse) { option (google.api.http) = { @@ -125,11 +184,12 @@ service UserService { org_field: "organization" } http_response: { - success_code: 201 + success_code: 200 } }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; responses: { key: "200" value: { @@ -163,6 +223,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -204,6 +270,8 @@ service UserService { // Change the user email // + // Deprecated: [Update the users email field](apis/resources/user_service_v2/user-service-update-user.api.mdx). + // // Change the email address of a user. If the state is set to not verified, a verification code will be generated, which can be either returned or sent to the user by email.. rpc SetEmail (SetEmailRequest) returns (SetEmailResponse) { option (google.api.http) = { @@ -218,18 +286,23 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; responses: { key: "200" value: { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } // Resend code to verify user email - // - // Resend code to verify user email. rpc ResendEmailCode (ResendEmailCodeRequest) returns (ResendEmailCodeResponse) { option (google.api.http) = { post: "/v2/users/{user_id}/email/resend" @@ -249,12 +322,16 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } // Send code to verify user email - // - // Send code to verify user email. rpc SendEmailCode (SendEmailCodeRequest) returns (SendEmailCodeResponse) { option (google.api.http) = { post: "/v2/users/{user_id}/email/send" @@ -274,6 +351,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -299,11 +382,19 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } // Set the user phone // + // Deprecated: [Update the users phone field](apis/resources/user_service_v2/user-service-update-user.api.mdx). + // // Set the phone number of a user. If the state is set to not verified, a verification code will be generated, which can be either returned or sent to the user by sms.. rpc SetPhone(SetPhoneRequest) returns (SetPhoneResponse) { option (google.api.http) = { @@ -318,18 +409,27 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; responses: { key: "200" value: { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } - // Remove the user phone + // Delete the user phone // - // Remove the user phone + // Deprecated: [Update the users phone field](apis/resources/user_service_v2/user-service-update-user.api.mdx) to remove the phone number. + // + // Delete the phone number of a user. rpc RemovePhone(RemovePhoneRequest) returns (RemovePhoneResponse) { option (google.api.http) = { delete: "/v2/users/{user_id}/phone" @@ -343,20 +443,23 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Delete the user phone"; - description: "Delete the phone number of a user." + deprecated: true; responses: { key: "200" value: { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } // Resend code to verify user phone - // - // Resend code to verify user phone. rpc ResendPhoneCode (ResendPhoneCodeRequest) returns (ResendPhoneCodeResponse) { option (google.api.http) = { post: "/v2/users/{user_id}/phone/resend" @@ -376,6 +479,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -401,15 +510,27 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } - // Update User + + // Update a User // - // Update all information from a user.. - rpc UpdateHumanUser(UpdateHumanUserRequest) returns (UpdateHumanUserResponse) { + // Partially update an existing user. + // If you change the users email or phone, you can specify how the ownership should be verified. + // If you change the users password, you can specify if the password should be changed again on the users next login. + // + // Required permission: + // - user.write + rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse) { option (google.api.http) = { - put: "/v2/users/human/{user_id}" + patch: "/v2/users/{user_id}" body: "*" }; @@ -426,6 +547,62 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + } + responses: { + key: "409" + value: { + description: "The user already exists."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // Update Human User + // + // Deprecated: Use [UpdateUser](apis/resources/user_service_v2/user-service-update-user.api.mdx) to update a user of type human instead. + // + // Update all information from a user.. + rpc UpdateHumanUser(UpdateHumanUserRequest) returns (UpdateHumanUserResponse) { + option (google.api.http) = { + put: "/v2/users/human/{user_id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; + responses: { + key: "200" + value: { + description: "OK"; + } + }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -451,6 +628,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -476,6 +659,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -501,6 +690,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -526,6 +721,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -550,6 +751,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -574,6 +781,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -598,6 +811,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -622,6 +841,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -670,6 +895,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -694,6 +925,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -718,6 +955,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -743,6 +986,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -767,6 +1016,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -791,6 +1046,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -814,6 +1075,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -838,6 +1105,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -861,6 +1134,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -885,6 +1164,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -908,6 +1193,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -958,6 +1249,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "Intent ID does not exist."; + } + } }; } @@ -983,6 +1280,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -1033,6 +1336,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -1058,11 +1367,19 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } // Change password // + // Deprecated: [Update the users password](apis/resources/user_service_v2/user-service-update-user.api.mdx) instead. + // // Change the password of a user with either a verification code or the current password.. rpc SetPassword (SetPasswordRequest) returns (SetPasswordResponse) { option (google.api.http) = { @@ -1077,12 +1394,19 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; responses: { key: "200" value: { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -1155,6 +1479,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -1183,6 +1513,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -1209,6 +1545,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -1234,9 +1576,289 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } + // Add a Users Secret + // + // Generates a client secret for the user. + // The client id is the users username. + // If the user already has a secret, it is overwritten. + // Only users of type machine can have a secret. + // + // Required permission: + // - user.write + rpc AddSecret(AddSecretRequest) returns (AddSecretResponse) { + option (google.api.http) = { + post: "/v2/users/{user_id}/secret" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The secret was successfully generated."; + } + }; + responses: { + key: "404" + value: { + description: "The user ID does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // Remove a Users Secret + // + // Remove the current client ID and client secret from a machine user. + // + // Required permission: + // - user.write + rpc RemoveSecret(RemoveSecretRequest) returns (RemoveSecretResponse) { + option (google.api.http) = { + delete: "/v2/users/{user_id}/secret" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The secret was either successfully removed or it didn't exist in the first place."; + } + }; + }; + } + + // Add a Key + // + // Add a keys that can be used to securely authenticate at the Zitadel APIs using JWT profile authentication using short-lived tokens. + // Make sure you store the returned key safely, as you won't be able to read it from the Zitadel API anymore. + // Only users of type machine can have keys. + // + // Required permission: + // - user.write + rpc AddKey(AddKeyRequest) returns (AddKeyResponse) { + option (google.api.http) = { + post: "/v2/users/{user_id}/keys" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The key was successfully created."; + } + }; + responses: { + key: "404" + value: { + description: "The user ID does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // Remove a Key + // + // Remove a machine users key by the given key ID and an optionally given user ID. + // + // Required permission: + // - user.write + rpc RemoveKey(RemoveKeyRequest) returns (RemoveKeyResponse) { + option (google.api.http) = { + delete: "/v2/users/{user_id}/keys/{key_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The key was either successfully removed or it not found in the first place."; + } + }; + }; + } + + // Search Keys + // + // List all matching keys. By default all keys of the instance on which the caller has permission to read the owning users are returned. + // Make sure to include a limit and sorting for pagination. + // + // Required permission: + // - user.read + rpc ListKeys(ListKeysRequest) returns (ListKeysResponse) { + option (google.api.http) = { + post: "/v2/users/keys/search" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all machine user keys matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + }; + }; + }; + } + + // Add a Personal Access Token + // + // Personal access tokens (PAT) are the easiest way to authenticate to the Zitadel APIs. + // Make sure you store the returned PAT safely, as you won't be able to read it from the Zitadel API anymore. + // Only users of type machine can have personal access tokens. + // + // Required permission: + // - user.write + rpc AddPersonalAccessToken(AddPersonalAccessTokenRequest) returns (AddPersonalAccessTokenResponse) { + option (google.api.http) = { + post: "/v2/users/{user_id}/pats" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The personal access token was successfully created."; + } + }; + responses: { + key: "404" + value: { + description: "The user ID does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // Remove a Personal Access Token + // + // Removes a machine users personal access token by the given token ID and an optionally given user ID. + // + // Required permission: + // - user.write + rpc RemovePersonalAccessToken(RemovePersonalAccessTokenRequest) returns (RemovePersonalAccessTokenResponse) { + option (google.api.http) = { + delete: "/v2/users/{user_id}/pats/{token_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The personal access token was either successfully removed or it was not found in the first place."; + } + }; + }; + } + + // Search Personal Access Tokens + // + // List all personal access tokens. By default all personal access tokens of the instance on which the caller has permission to read the owning users are returned. + // Make sure to include a limit and sorting for pagination. + // + // Required permission: + // - user.read + rpc ListPersonalAccessTokens(ListPersonalAccessTokensRequest) returns (ListPersonalAccessTokensResponse) { + option (google.api.http) = { + post: "/v2/users/pats/search" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all personal access tokens matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + }; + }; + }; + } } message AddHumanUserRequest{ @@ -1296,6 +1918,149 @@ message AddHumanUserResponse { optional string phone_code = 4; } + +message CreateUserRequest{ + message Human { + // Set the users profile information. + SetHumanProfile profile = 1 [ + (validate.rules).message.required = true, + (google.api.field_behavior) = REQUIRED + ]; + // Set the users email address and optionally send a verification email. + SetHumanEmail email = 2 [ + (validate.rules).message.required = true, + (google.api.field_behavior) = REQUIRED + ]; + // Set the users phone number and optionally send a verification SMS. + optional SetHumanPhone phone = 3; + // Set the users initial password and optionally require the user to set a new password. + oneof password_type { + Password password = 4; + HashedPassword hashed_password = 5; + } + // Create the user with a list of links to identity providers. + // This can be useful in migration-scenarios. + // For example, if a user already has an account in an external identity provider or another Zitadel instance, an IDP link allows the user to authenticate as usual. + // Sessions, second factors, hardware keys registered externally are still available for authentication. + // Use the following endpoints to manage identity provider links: + // - [AddIDPLink](apis/resources/user_service_v2/user-service-add-idp-link.api.mdx) + // - [RemoveIDPLink](apis/resources/user_service_v2/user-service-remove-idp-link.api.mdx) + repeated IDPLink idp_links = 7; + // An Implementation of RFC 6238 is used, with HMAC-SHA-1 and time-step of 30 seconds. + // Currently no other options are supported, and if anything different is used the validation will fail. + optional string totp_secret = 8 [ + (validate.rules).string = {min_len: 1 max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"TJOPWSDYILLHXFV4MLKNNJOWFG7VSDCK\""; + } + ]; + } + message Machine { + // The machine users name is a human readable field that helps identifying the user. + string name = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"Acceptance Test User\""; + } + ]; + // The description is a field that helps to remember the purpose of the user. + optional string description = 2 [ + (validate.rules).string = {min_len: 1, max_len: 500}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 500, + example: "\"The user calls the session API in the continuous integration pipeline for acceptance tests.\""; + } + ]; + } + // The unique identifier of the organization the user belongs to. + string organization_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; + // The ID is a unique identifier for the user in the instance. + // If not specified, it will be generated. + // You can set your own user id that is unique within the instance. + // This is useful in migration scenarios, for example if the user already has an ID in another Zitadel system. + // If not specified, it will be generated. + // It can't be changed after creation. + optional string user_id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"163840776835432345\""; + } + ]; + + // The username is a unique identifier for the user in the organization. + // If not specified, Zitadel sets the username to the email for users of type human and to the user_id for users of type machine. + // It is used to identify the user in the organization and can be used for login. + optional string username = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"minnie-mouse\""; + } + ]; + + // The type of the user. + oneof user_type { + option (validate.required) = true; + // Users of type human are users that are meant to be used by a person. + // They can log in interactively using a login UI. + // By default, new users will receive a verification email and, if a phone is configured, a verification SMS. + // To make sure these messages are sent, configure and activate valid SMTP and Twilio configurations. + // Read more about your options for controlling this behaviour in the email and phone field documentations. + Human human = 4; + // Users of type machine are users that are meant to be used by a machine. + // In order to authenticate, [add a secret](apis/resources/user_service_v2/user-service-add-secret.api.mdx), [a key](apis/resources/user_service_v2/user-service-add-key.api.mdx) or [a personal access token](apis/resources/user_service_v2/user-service-add-personal-access-token.api.mdx) to the user. + // Tokens generated for new users of type machine will be of an opaque Bearer type. + // You can change the users token type to JWT by using the [management v1 service method UpdateMachine](apis/resources/mgmt/management-service-update-machine.api.mdx). + Machine machine = 5; + } + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"organizationId\":\"69629026806489455\",\"userId\":\"163840776835432345\",\"username\":\"minnie-mouse\",\"human\":{\"profile\":{\"givenName\":\"Minnie\",\"familyName\":\"Mouse\",\"nickName\":\"Mini\",\"displayName\":\"Minnie Mouse\",\"preferredLanguage\":\"en\",\"gender\":\"GENDER_FEMALE\"},\"email\":{\"email\":\"mini@mouse.com\",\"sendCode\":{\"urlTemplate\":\"https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}\"}},\"phone\":{\"phone\":\"+41791234567\",\"isVerified\":true},\"password\":{\"password\":\"Secr3tP4ssw0rd!\",\"changeRequired\":true},\"idpLinks\":[{\"idpId\":\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\",\"userId\":\"6516849804890468048461403518\",\"userName\":\"user@external.com\"}],\"totpSecret\":\"TJOPWSDYILLHXFV4MLKNNJOWFG7VSDCK\"}}"; + }; +} + +message CreateUserResponse { + // The unique identifier of the newly created user. + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The timestamp of the user creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + // The email verification code if it was requested by setting the email verification to return_code. + optional string email_code = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"XTA6BC\""; + } + ]; + // The phone verification code if it was requested by setting the phone verification to return_code. + optional string phone_code = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"XTA6BC\""; + } + ]; +} + message GetUserByIDRequest { reserved 2; reserved "organization"; @@ -1550,6 +2315,142 @@ message DeleteUserResponse { zitadel.object.v2.Details details = 1; } + +message UpdateUserRequest{ + message Human { + message Profile { + // The given name is the first name of the user. + // For example, it can be used to personalize notifications and login UIs. + optional string given_name = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"Minnie\""; + } + ]; + // The family name is the last name of the user. + // For example, it can be used to personalize user interfaces and notifications. + optional string family_name = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"Mouse\""; + } + ]; + // The nick name is the users short name. + // For example, it can be used to personalize user interfaces and notifications. + optional string nick_name = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"Mini\""; + } + ]; + // The display name is how a user should primarily be displayed in lists. + // It can also for example be used to personalize user interfaces and notifications. + optional string display_name = 4 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"Minnie Mouse\""; + } + ]; + // The users preferred language is the language that systems should use to interact with the user. + // It has the format of a [BCP-47 language tag](https://datatracker.ietf.org/doc/html/rfc3066). + // It is used by Zitadel where no higher prioritized preferred language can be used. + // For example, browser settings can overwrite a users preferred_language. + // Notification messages and standard login UIs use the users preferred language if it is supported and allowed on the instance. + // Else, the default language of the instance is used. + optional string preferred_language = 5 [ + (validate.rules).string = {min_len: 1, max_len: 10}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 10; + example: "\"en-US\""; + } + ]; + // The users gender can for example be used to personalize user interfaces and notifications. + optional Gender gender = 6 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"GENDER_FEMALE\""; + } + ]; + } + // Change the users profile information + optional Profile profile = 1; + // Change the users email address and/or trigger a verification email + optional SetHumanEmail email = 2; + // Change the users phone number and/or trigger a verification SMS + // To delete the users phone number, leave the phone field empty and omit the verification field. + optional SetHumanPhone phone = 3; + // Change the users password. + // You can optionally require the current password or the verification code to be correct. + optional SetPassword password = 4; + } + message Machine { + // The machine users name is a human readable field that helps identifying the user. + optional string name = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Acceptance Test User\""; + } + ]; + // The description is a field that helps to remember the purpose of the user. + optional string description = 2 [ + (validate.rules).string = {max_len: 500}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"The user calls the session API in the continuous integration pipeline for acceptance tests.\""; + } + ]; + } + // The user id is the users unique identifier in the instance. + // It can't be changed. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; + // Set a new username that is unique within the instance. + // Beware that active tokens and sessions are invalidated when the username is changed. + optional string username = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"minnie-mouse\""; + } + ]; + // Change type specific properties of the user. + oneof user_type { + Human human = 3; + Machine machine = 4; + } + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"username\":\"minnie-mouse\",\"human\":{\"profile\":{\"givenName\":\"Minnie\",\"familyName\":\"Mouse\",\"displayName\":\"Minnie Mouse\"},\"email\":{\"email\":\"mini@mouse.com\",\"returnCode\":{}},\"phone\":{\"phone\":\"+41791234567\",\"isVerified\":true},\"password\":{\"password\":{\"password\":\"Secr3tP4ssw0rd!\",\"changeRequired\":true},\"verificationCode\":\"SKJd342k\"},\"totpSecret\":\"TJOPWSDYILLHXFV4MLKNNJOWFG7VSDCK\"}}"; + }; +} + +message UpdateUserResponse { + // The timestamp of the change of the user. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + // In case the email verification was set to return_code, the code will be returned + optional string email_code = 2; + // In case the phone verification was set to return_code, the code will be returned + optional string phone_code = 3; +} + message UpdateHumanUserRequest{ string user_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, @@ -1595,7 +2496,6 @@ message DeactivateUserResponse { zitadel.object.v2.Details details = 1; } - message ReactivateUserRequest { string user_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, @@ -2384,3 +3284,237 @@ message HumanMFAInitSkippedRequest { message HumanMFAInitSkippedResponse { zitadel.object.v2.Details details = 1; } + +message AddSecretRequest { + // The users resource ID. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"163840776835432345\""; + } + ]; +} + +message AddSecretResponse { + // The timestamp of the secret creation. + google.protobuf.Timestamp creation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The client secret. + // Store this secret in a secure place. + // It is not possible to retrieve it again. + string client_secret = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"WoYLHB23HAZaCSxeMJGEzbu8urHICVdFp2IegVr6Q5U4lZHKAtRvmaalNDWfCuHV\""; + } + ]; +} + +message RemoveSecretRequest { + // The users resource ID. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"163840776835432345\""; + } + ]; +} + +message RemoveSecretResponse { + // The timestamp of the secret deletion. + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message AddKeyRequest { + // The users resource ID. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; + // The date the key will expire and no logins will be possible anymore. + google.protobuf.Timestamp expiration_date = 2 [ + (validate.rules).timestamp.required = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2519-04-01T08:45:00.000000Z\""; + } + ]; + // Optionally provide a public key of your own generated RSA private key. + bytes public_key = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1...\""; + } + ]; +} + +message AddKeyResponse { + // The timestamp of the key creation. + google.protobuf.Timestamp creation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The keys ID. + string key_id = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"163840776835432345\""; + } + ]; + // The key which is usable to authenticate against the API. + bytes key_content = 3; +} + + +message RemoveKeyRequest { + // The users resource ID. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; + // The keys ID. + string key_id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; +} + +message RemoveKeyResponse { + // The timestamp of the key deletion. + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message ListKeysRequest { + // List limitations and ordering. + optional zitadel.filter.v2.PaginationRequest pagination = 1; + // The field the result is sorted by. The default is the creation date. Beware that if you change this, your result pagination might be inconsistent. + optional KeyFieldName sorting_column = 2 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"KEY_FIELD_NAME_CREATED_DATE\"" + } + ]; + // Define the criteria to query for. + repeated KeysSearchFilter filters = 3; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"pagination\":{\"offset\":0,\"limit\":10,\"asc\":false},\"sortingColumn\":\"KEY_FIELD_NAME_CREATED_DATE\",\"filters\":[{\"andFilter\":{\"filters\":[{\"organizationIdFilter\":{\"organizationId\":\"163840776835432345\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}},{\"notFilter\":{\"filter\":{\"userIdFilter\":{\"userId\":\"163840776835432345\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}}}},{\"orFilter\":{\"filters\":[{\"keyIdFilter\":{\"keyId\":\"163840776835432345\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}},{\"keyIdFilter\":{\"keyId\":\"163840776835432345\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}}]}}]}}]}"; + }; +} + +message ListKeysResponse { + zitadel.filter.v2.PaginationResponse pagination = 1; + repeated Key result = 2; +} + +message AddPersonalAccessTokenRequest { + // The users resource ID. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; + // The timestamp when the token will expire. + google.protobuf.Timestamp expiration_date = 2 [ + (validate.rules).timestamp.required = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2519-04-01T08:45:00.000000Z\""; + } + ]; +} + +message AddPersonalAccessTokenResponse { + // The timestamp of the personal access token creation. + google.protobuf.Timestamp creation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The tokens ID. + string token_id = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"163840776835432345\""; + } + ]; + // The personal access token that can be used to authenticate against the API + string token = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c\""; + } + ]; +} + +message RemovePersonalAccessTokenRequest { + // The users resource ID. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; + // The tokens ID. + string token_id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; +} + +message RemovePersonalAccessTokenResponse { + // The timestamp of the personal access token deletion. + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + + +message ListPersonalAccessTokensRequest { + // List limitations and ordering. + optional zitadel.filter.v2.PaginationRequest pagination = 1; + // The field the result is sorted by. The default is the creation date. Beware that if you change this, your result pagination might be inconsistent. + optional PersonalAccessTokenFieldName sorting_column = 2 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"PERSONAL_ACCESS_TOKEN_FIELD_NAME_CREATED_DATE\"" + } + ]; + // Define the criteria to query for. + repeated PersonalAccessTokensSearchFilter filters = 3; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"pagination\":{\"offset\":0,\"limit\":10,\"asc\":false},\"sortingColumn\":\"PERSONAL_ACCESS_TOKEN_FIELD_NAME_CREATED_DATE\",\"filters\":[{\"andFilter\":{\"filters\":[{\"organizationIdFilter\":{\"organizationId\":\"163840776835432345\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}},{\"notFilter\":{\"filter\":{\"userIdFilter\":{\"userId\":\"163840776835432345\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}}}},{\"orFilter\":{\"filters\":[{\"personalAccessTokenIdFilter\":{\"personalAccessTokenId\":\"163840776835432345\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}},{\"personalAccessTokenIdFilter\":{\"personalAccessTokenId\":\"163840776835432345\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}}]}}]}}]}"; + }; +} + +message ListPersonalAccessTokensResponse { + zitadel.filter.v2.PaginationResponse pagination = 1; + repeated PersonalAccessToken result = 2; +}