From 2f35c7b267202babbcd19b1f194997c215822f34 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 15:10:24 +0000 Subject: [PATCH 01/14] feat(api): accept user and team locators on grants Create, get, and list name the bound person as user or team instead of principal_type / principal_id. Create resolves user_id, identifier, team_id, or team name in the platform project; storage and events stay id-based. Expand inlines extras onto the same user / team ref. Co-authored-by: Silvan --- .changeset/grant-api-locators.md | 5 + api/generated/oas_client_gen.go | 66 +- api/generated/oas_handlers_gen.go | 24 +- api/generated/oas_json_gen.go | 1480 ++++++++++------- api/generated/oas_parameters_gen.go | 91 + api/generated/oas_schemas_gen.go | 1062 ++++++++---- api/generated/oas_server_gen.go | 20 +- api/generated/oas_unimplemented_gen.go | 20 +- api/generated/oas_validators_gen.go | 291 +++- .../grants/by_id/getGrant-error-response.yaml | 14 + .../endpoints/grants/by_id/methods.yaml | 13 + .../grants/create-grant-request.yaml | 31 +- .../grants/grant-expanded-principal.yaml | 21 - api/openapi/endpoints/grants/grant-team.yaml | 25 + api/openapi/endpoints/grants/grant-user.yaml | 27 + api/openapi/endpoints/grants/grant.yaml | 51 +- api/openapi/endpoints/grants/methods.yaml | 10 +- .../endpoints/grants/query/grant-expand.yaml | 16 +- .../grants/query/grant-filter-field.yaml | 8 +- .../endpoints/grants/query/methods.yaml | 8 +- .../grants/query/query-grants-request.yaml | 5 +- .../endpoints/grants/team-locator.yaml | 18 + .../endpoints/grants/user-locator.yaml | 19 + .../src/components/add-admin-dialog.tsx | 4 +- .../routes/_authed/settings/admins.spec.tsx | 48 +- .../src/routes/_authed/settings/admins.tsx | 38 +- .../adrs/054-customer-collaboration-grants.md | 19 +- docs/adrs/059-expanding-embedded-objects.md | 27 + docs/adrs/README.md | 2 +- internal/api/grant.go | 170 +- internal/api/grant_internal_test.go | 155 +- internal/api/integration_test/grant_test.go | 255 ++- internal/service/grant.go | 285 +++- internal/service/grant_test.go | 281 +++- 34 files changed, 3164 insertions(+), 1445 deletions(-) create mode 100644 .changeset/grant-api-locators.md delete mode 100644 api/openapi/endpoints/grants/grant-expanded-principal.yaml create mode 100644 api/openapi/endpoints/grants/grant-team.yaml create mode 100644 api/openapi/endpoints/grants/grant-user.yaml create mode 100644 api/openapi/endpoints/grants/team-locator.yaml create mode 100644 api/openapi/endpoints/grants/user-locator.yaml diff --git a/.changeset/grant-api-locators.md b/.changeset/grant-api-locators.md new file mode 100644 index 000000000..87ba4f2b4 --- /dev/null +++ b/.changeset/grant-api-locators.md @@ -0,0 +1,5 @@ +--- +"@zitadel/server": minor +--- + +Callers can create grants by user identifier or team name, not only by id. Create, get, and list name the bound person as `user` (`user_id`) or `team` (`team_id`) and drop `principal_type` / `principal_id`. `expand: ["principal"]` adds extra fields on that same object. diff --git a/api/generated/oas_client_gen.go b/api/generated/oas_client_gen.go index ea9741e99..a207bd916 100644 --- a/api/generated/oas_client_gen.go +++ b/api/generated/oas_client_gen.go @@ -101,10 +101,12 @@ type Invoker interface { // // Bind a user or team to `project.viewer`, `project.editor`, or // `project.admin` on the project identified by the `project-id` header. - // IDs are `asgn_`. Owning-team (`project.team`) grants are not - // created here — claim owns that path. An unrevoked grant with the same - // principal and relation occupies the unique key even after `expires_at`; - // DELETE it before re-creating. + // Name the principal with `user` (`user_id` or `identifier`) or `team` + // (`team_id` or `name`). IDs are `asgn_`. Owning-team + // (`project.team`) grants are not created here — claim owns that path. An + // unrevoked grant with the same principal and relation occupies the unique + // key even after `expires_at`; DELETE it before re-creating. + // Create does not accept `expand`; the 201 `user` / `team` are refs only. // // POST /grants CreateGrant(ctx context.Context, request *CreateGrantRequest, params CreateGrantParams) (CreateGrantRes, error) @@ -336,6 +338,8 @@ type Invoker interface { // `resource_scope_index`; project scope is required on the query (same as // events). Misses, revoked rows, project-secret setup (`sk_proj`), // owning-team (`relation=team`) rows, and cross-project ids return 404. + // `expand=principal` adds envelope fields on `user` or `team` and requires + // `user.read` and `team.read` in addition to `project.read`. // // GET /grants/{id} GetGrant(ctx context.Context, params GetGrantParams) (GetGrantRes, error) @@ -526,10 +530,10 @@ type Invoker interface { // DELETE before re-granting. Project-secret setup (`sk_proj`) and // owning-team (`relation=team`) rows are not returned. Grants are not in // `resource_scope_index`; project scope is required on the query (same as - // get). Requires `project.read`. `expand: ["principal"]` additionally - // requires `user.read` and `team.read` (documented on the expand enum; - // those scopes cannot be ANDed onto this security block because they are - // body-conditional). + // get). Requires `project.read`. `expand: ["principal"]` adds envelope + // fields on `user` / `team` and additionally requires `user.read` and + // `team.read` (documented on the expand enum; those scopes cannot be ANDed + // onto this security block because they are body-conditional). // // POST /grants/query QueryGrants(ctx context.Context, request *QueryGrantsRequest, params QueryGrantsParams) (QueryGrantsRes, error) @@ -1468,10 +1472,12 @@ func (c *Client) sendCreateFlowDefinition(ctx context.Context, request *CreateFl // // Bind a user or team to `project.viewer`, `project.editor`, or // `project.admin` on the project identified by the `project-id` header. -// IDs are `asgn_`. Owning-team (`project.team`) grants are not -// created here — claim owns that path. An unrevoked grant with the same -// principal and relation occupies the unique key even after `expires_at`; -// DELETE it before re-creating. +// Name the principal with `user` (`user_id` or `identifier`) or `team` +// (`team_id` or `name`). IDs are `asgn_`. Owning-team +// (`project.team`) grants are not created here — claim owns that path. An +// unrevoked grant with the same principal and relation occupies the unique +// key even after `expires_at`; DELETE it before re-creating. +// Create does not accept `expand`; the 201 `user` / `team` are refs only. // // POST /grants func (c *Client) CreateGrant(ctx context.Context, request *CreateGrantRequest, params CreateGrantParams) (CreateGrantRes, error) { @@ -4526,6 +4532,8 @@ func (c *Client) sendGetFlowStep(ctx context.Context, params GetFlowStepParams) // `resource_scope_index`; project scope is required on the query (same as // events). Misses, revoked rows, project-secret setup (`sk_proj`), // owning-team (`relation=team`) rows, and cross-project ids return 404. +// `expand=principal` adds envelope fields on `user` or `team` and requires +// `user.read` and `team.read` in addition to `project.read`. // // GET /grants/{id} func (c *Client) GetGrant(ctx context.Context, params GetGrantParams) (GetGrantRes, error) { @@ -4611,6 +4619,32 @@ func (c *Client) sendGetGrant(ctx context.Context, params GetGrantParams) (res G return res, errors.Wrap(err, "encode query") } } + { + // Encode "expand" parameter. + cfg := uri.QueryParameterEncodingConfig{ + Name: "expand", + Style: uri.QueryStyleForm, + Explode: true, + } + + if err := q.EncodeParam(cfg, func(e uri.Encoder) error { + if params.Expand != nil { + return e.EncodeArray(func(e uri.Encoder) error { + for i, item := range params.Expand { + if err := func() error { + return e.EncodeValue(conv.StringToString(string(item))) + }(); err != nil { + return errors.Wrapf(err, "[%d]", i) + } + } + return nil + }) + } + return nil + }); err != nil { + return res, errors.Wrap(err, "encode query") + } + } u.RawQuery = q.Values().Encode() stage = "EncodeRequest" @@ -8076,10 +8110,10 @@ func (c *Client) sendPatchProject(ctx context.Context, request *PatchProjectRequ // DELETE before re-granting. Project-secret setup (`sk_proj`) and // owning-team (`relation=team`) rows are not returned. Grants are not in // `resource_scope_index`; project scope is required on the query (same as -// get). Requires `project.read`. `expand: ["principal"]` additionally -// requires `user.read` and `team.read` (documented on the expand enum; -// those scopes cannot be ANDed onto this security block because they are -// body-conditional). +// get). Requires `project.read`. `expand: ["principal"]` adds envelope +// fields on `user` / `team` and additionally requires `user.read` and +// `team.read` (documented on the expand enum; those scopes cannot be ANDed +// onto this security block because they are body-conditional). // // POST /grants/query func (c *Client) QueryGrants(ctx context.Context, request *QueryGrantsRequest, params QueryGrantsParams) (QueryGrantsRes, error) { diff --git a/api/generated/oas_handlers_gen.go b/api/generated/oas_handlers_gen.go index da1d17032..25a2ee7b9 100644 --- a/api/generated/oas_handlers_gen.go +++ b/api/generated/oas_handlers_gen.go @@ -1193,10 +1193,12 @@ func (s *Server) handleCreateFlowDefinitionRequest(args [0]string, argsEscaped b // // Bind a user or team to `project.viewer`, `project.editor`, or // `project.admin` on the project identified by the `project-id` header. -// IDs are `asgn_`. Owning-team (`project.team`) grants are not -// created here — claim owns that path. An unrevoked grant with the same -// principal and relation occupies the unique key even after `expires_at`; -// DELETE it before re-creating. +// Name the principal with `user` (`user_id` or `identifier`) or `team` +// (`team_id` or `name`). IDs are `asgn_`. Owning-team +// (`project.team`) grants are not created here — claim owns that path. An +// unrevoked grant with the same principal and relation occupies the unique +// key even after `expires_at`; DELETE it before re-creating. +// Create does not accept `expand`; the 201 `user` / `team` are refs only. // // POST /grants func (s *Server) handleCreateGrantRequest(args [0]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { @@ -5427,6 +5429,8 @@ func (s *Server) handleGetFlowStepRequest(args [1]string, argsEscaped bool, w ht // `resource_scope_index`; project scope is required on the query (same as // events). Misses, revoked rows, project-secret setup (`sk_proj`), // owning-team (`relation=team`) rows, and cross-project ids return 404. +// `expand=principal` adds envelope fields on `user` or `team` and requires +// `user.read` and `team.read` in addition to `project.read`. // // GET /grants/{id} func (s *Server) handleGetGrantRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { @@ -5575,6 +5579,10 @@ func (s *Server) handleGetGrantRequest(args [1]string, argsEscaped bool, w http. Name: "project_id", In: "query", }: params.ProjectID, + { + Name: "expand", + In: "query", + }: params.Expand, }, Raw: r, } @@ -9743,10 +9751,10 @@ func (s *Server) handlePatchProjectRequest(args [1]string, argsEscaped bool, w h // DELETE before re-granting. Project-secret setup (`sk_proj`) and // owning-team (`relation=team`) rows are not returned. Grants are not in // `resource_scope_index`; project scope is required on the query (same as -// get). Requires `project.read`. `expand: ["principal"]` additionally -// requires `user.read` and `team.read` (documented on the expand enum; -// those scopes cannot be ANDed onto this security block because they are -// body-conditional). +// get). Requires `project.read`. `expand: ["principal"]` adds envelope +// fields on `user` / `team` and additionally requires `user.read` and +// `team.read` (documented on the expand enum; those scopes cannot be ANDed +// onto this security block because they are body-conditional). // // POST /grants/query func (s *Server) handleQueryGrantsRequest(args [0]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { diff --git a/api/generated/oas_json_gen.go b/api/generated/oas_json_gen.go index 8ddb2b685..03b93ab55 100644 --- a/api/generated/oas_json_gen.go +++ b/api/generated/oas_json_gen.go @@ -15563,14 +15563,6 @@ func (s *CreateGrantRequest) Encode(e *jx.Encoder) { // encodeFields encodes fields. func (s *CreateGrantRequest) encodeFields(e *jx.Encoder) { - { - e.FieldStart("principal_type") - s.PrincipalType.Encode(e) - } - { - e.FieldStart("principal_id") - e.Str(s.PrincipalID) - } { e.FieldStart("relation") s.Relation.Encode(e) @@ -15581,13 +15573,25 @@ func (s *CreateGrantRequest) encodeFields(e *jx.Encoder) { s.ExpiresAt.Encode(e, json.EncodeDateTime) } } + { + if s.User.Set { + e.FieldStart("user") + s.User.Encode(e) + } + } + { + if s.Team.Set { + e.FieldStart("team") + s.Team.Encode(e) + } + } } var jsonFieldsNameOfCreateGrantRequest = [4]string{ - 0: "principal_type", - 1: "principal_id", - 2: "relation", - 3: "expires_at", + 0: "relation", + 1: "expires_at", + 2: "user", + 3: "team", } // Decode decodes CreateGrantRequest from json. @@ -15599,47 +15603,45 @@ func (s *CreateGrantRequest) Decode(d *jx.Decoder) error { if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { switch string(k) { - case "principal_type": + case "relation": requiredBitSet[0] |= 1 << 0 if err := func() error { - if err := s.PrincipalType.Decode(d); err != nil { + if err := s.Relation.Decode(d); err != nil { return err } return nil }(); err != nil { - return errors.Wrap(err, "decode field \"principal_type\"") + return errors.Wrap(err, "decode field \"relation\"") } - case "principal_id": - requiredBitSet[0] |= 1 << 1 + case "expires_at": if err := func() error { - v, err := d.Str() - s.PrincipalID = string(v) - if err != nil { + s.ExpiresAt.Reset() + if err := s.ExpiresAt.Decode(d, json.DecodeDateTime); err != nil { return err } return nil }(); err != nil { - return errors.Wrap(err, "decode field \"principal_id\"") + return errors.Wrap(err, "decode field \"expires_at\"") } - case "relation": - requiredBitSet[0] |= 1 << 2 + case "user": if err := func() error { - if err := s.Relation.Decode(d); err != nil { + s.User.Reset() + if err := s.User.Decode(d); err != nil { return err } return nil }(); err != nil { - return errors.Wrap(err, "decode field \"relation\"") + return errors.Wrap(err, "decode field \"user\"") } - case "expires_at": + case "team": if err := func() error { - s.ExpiresAt.Reset() - if err := s.ExpiresAt.Decode(d, json.DecodeDateTime); err != nil { + s.Team.Reset() + if err := s.Team.Decode(d); err != nil { return err } return nil }(); err != nil { - return errors.Wrap(err, "decode field \"expires_at\"") + return errors.Wrap(err, "decode field \"team\"") } default: return d.Skip() @@ -15651,7 +15653,7 @@ func (s *CreateGrantRequest) Decode(d *jx.Decoder) error { // Validate required fields. var failures []validate.FieldError for i, mask := range [1]uint8{ - 0b00000111, + 0b00000001, } { if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { // Mask only required fields and check equality to mask using XOR. @@ -15697,46 +15699,6 @@ func (s *CreateGrantRequest) UnmarshalJSON(data []byte) error { return s.Decode(d) } -// Encode encodes CreateGrantRequestPrincipalType as json. -func (s CreateGrantRequestPrincipalType) Encode(e *jx.Encoder) { - e.Str(string(s)) -} - -// Decode decodes CreateGrantRequestPrincipalType from json. -func (s *CreateGrantRequestPrincipalType) Decode(d *jx.Decoder) error { - if s == nil { - return errors.New("invalid: unable to decode CreateGrantRequestPrincipalType to nil") - } - v, err := d.StrBytes() - if err != nil { - return err - } - // Try to use constant string. - switch CreateGrantRequestPrincipalType(v) { - case CreateGrantRequestPrincipalTypeUser: - *s = CreateGrantRequestPrincipalTypeUser - case CreateGrantRequestPrincipalTypeTeam: - *s = CreateGrantRequestPrincipalTypeTeam - default: - *s = CreateGrantRequestPrincipalType(v) - } - - return nil -} - -// MarshalJSON implements stdjson.Marshaler. -func (s CreateGrantRequestPrincipalType) MarshalJSON() ([]byte, error) { - e := jx.Encoder{} - s.Encode(&e) - return e.Bytes(), nil -} - -// UnmarshalJSON implements stdjson.Unmarshaler. -func (s *CreateGrantRequestPrincipalType) UnmarshalJSON(data []byte) error { - d := jx.DecodeBytes(data) - return s.Decode(d) -} - // Encode encodes CreateGrantRequestRelation as json. func (s CreateGrantRequestRelation) Encode(e *jx.Encoder) { e.Str(string(s)) @@ -38097,6 +38059,38 @@ func (s GetGrantErrorResponse) encodeFields(e *jx.Encoder) { } } } + case TeamPermissionDeniedGetGrantErrorResponse: + e.FieldStart("code") + e.Str("team.permission_denied") + { + s := s.TeamPermissionDenied + { + e.FieldStart("message") + e.Str(s.Message) + } + { + if s.Details.Set { + e.FieldStart("details") + s.Details.Encode(e) + } + } + } + case UserPermissionDeniedGetGrantErrorResponse: + e.FieldStart("code") + e.Str("user.permission_denied") + { + s := s.UserPermissionDenied + { + e.FieldStart("message") + e.Str(s.Message) + } + { + if s.Details.Set { + e.FieldStart("details") + s.Details.Encode(e) + } + } + } } } @@ -38138,6 +38132,12 @@ func (s *GetGrantErrorResponse) Decode(d *jx.Decoder) error { case "req.invalid": s.Type = ReqInvalidGetGrantErrorResponse found = true + case "team.permission_denied": + s.Type = TeamPermissionDeniedGetGrantErrorResponse + found = true + case "user.permission_denied": + s.Type = UserPermissionDeniedGetGrantErrorResponse + found = true default: return errors.Errorf("unknown type %s", typ) } @@ -38172,6 +38172,14 @@ func (s *GetGrantErrorResponse) Decode(d *jx.Decoder) error { if err := s.ReqInvalid.Decode(d); err != nil { return err } + case TeamPermissionDeniedGetGrantErrorResponse: + if err := s.TeamPermissionDenied.Decode(d); err != nil { + return err + } + case UserPermissionDeniedGetGrantErrorResponse: + if err := s.UserPermissionDenied.Decode(d); err != nil { + return err + } default: return errors.Errorf("inferred invalid type: %s", s.Type) } @@ -39815,14 +39823,6 @@ func (s *Grant) encodeFields(e *jx.Encoder) { e.FieldStart("project_id") e.Str(s.ProjectID) } - { - e.FieldStart("principal_type") - s.PrincipalType.Encode(e) - } - { - e.FieldStart("principal_id") - e.Str(s.PrincipalID) - } { e.FieldStart("object_type") s.ObjectType.Encode(e) @@ -39853,26 +39853,17 @@ func (s *Grant) encodeFields(e *jx.Encoder) { s.Team.Encode(e) } } - { - if s.Principal.Set { - e.FieldStart("principal") - s.Principal.Encode(e) - } - } } -var jsonFieldsNameOfGrant = [11]string{ - 0: "id", - 1: "project_id", - 2: "principal_type", - 3: "principal_id", - 4: "object_type", - 5: "relation", - 6: "created_at", - 7: "expires_at", - 8: "user", - 9: "team", - 10: "principal", +var jsonFieldsNameOfGrant = [8]string{ + 0: "id", + 1: "project_id", + 2: "object_type", + 3: "relation", + 4: "created_at", + 5: "expires_at", + 6: "user", + 7: "team", } // Decode decodes Grant from json. @@ -39880,7 +39871,7 @@ func (s *Grant) Decode(d *jx.Decoder) error { if s == nil { return errors.New("invalid: unable to decode Grant to nil") } - var requiredBitSet [2]uint8 + var requiredBitSet [1]uint8 if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { switch string(k) { @@ -39908,30 +39899,8 @@ func (s *Grant) Decode(d *jx.Decoder) error { }(); err != nil { return errors.Wrap(err, "decode field \"project_id\"") } - case "principal_type": - requiredBitSet[0] |= 1 << 2 - if err := func() error { - if err := s.PrincipalType.Decode(d); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "decode field \"principal_type\"") - } - case "principal_id": - requiredBitSet[0] |= 1 << 3 - if err := func() error { - v, err := d.Str() - s.PrincipalID = string(v) - if err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "decode field \"principal_id\"") - } case "object_type": - requiredBitSet[0] |= 1 << 4 + requiredBitSet[0] |= 1 << 2 if err := func() error { if err := s.ObjectType.Decode(d); err != nil { return err @@ -39941,7 +39910,7 @@ func (s *Grant) Decode(d *jx.Decoder) error { return errors.Wrap(err, "decode field \"object_type\"") } case "relation": - requiredBitSet[0] |= 1 << 5 + requiredBitSet[0] |= 1 << 3 if err := func() error { if err := s.Relation.Decode(d); err != nil { return err @@ -39951,7 +39920,7 @@ func (s *Grant) Decode(d *jx.Decoder) error { return errors.Wrap(err, "decode field \"relation\"") } case "created_at": - requiredBitSet[0] |= 1 << 6 + requiredBitSet[0] |= 1 << 4 if err := func() error { v, err := json.DecodeDateTime(d) s.CreatedAt = v @@ -39992,16 +39961,6 @@ func (s *Grant) Decode(d *jx.Decoder) error { }(); err != nil { return errors.Wrap(err, "decode field \"team\"") } - case "principal": - if err := func() error { - s.Principal.Reset() - if err := s.Principal.Decode(d); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "decode field \"principal\"") - } default: return d.Skip() } @@ -40011,9 +39970,8 @@ func (s *Grant) Decode(d *jx.Decoder) error { } // Validate required fields. var failures []validate.FieldError - for i, mask := range [2]uint8{ - 0b01111111, - 0b00000000, + for i, mask := range [1]uint8{ + 0b00011111, } { if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { // Mask only required fields and check equality to mask using XOR. @@ -40285,222 +40243,6 @@ func (s *GrantExpand) UnmarshalJSON(data []byte) error { return s.Decode(d) } -// Encode encodes GrantExpandedPrincipal as json. -func (s GrantExpandedPrincipal) Encode(e *jx.Encoder) { - switch s.Type { - case UserGrantExpandedPrincipal: - s.User.Encode(e) - case TeamResponseGrantExpandedPrincipal: - s.TeamResponse.Encode(e) - } -} - -func (s GrantExpandedPrincipal) encodeFields(e *jx.Encoder) { - switch s.Type { - case UserGrantExpandedPrincipal: - s.User.encodeFields(e) - case TeamResponseGrantExpandedPrincipal: - s.TeamResponse.encodeFields(e) - } -} - -// Decode decodes GrantExpandedPrincipal from json. -func (s *GrantExpandedPrincipal) Decode(d *jx.Decoder) error { - if s == nil { - return errors.New("invalid: unable to decode GrantExpandedPrincipal to nil") - } - // Sum type fields. - if typ := d.Next(); typ != jx.Object { - return errors.Errorf("unexpected json type %q", typ) - } - - var found bool - if err := d.Capture(func(d *jx.Decoder) error { - return d.ObjBytes(func(d *jx.Decoder, key []byte) error { - switch string(key) { - case "attributes": - // Type-based discrimination: check if field has expected JSON type - if typ := d.Next(); typ != jx.Object { - // Field exists but has wrong type, not a match for this variant - return d.Skip() - } - match := UserGrantExpandedPrincipal - if found && s.Type != match { - s.Type = "" - return errors.Errorf("multiple oneOf matches: (%v, %v)", s.Type, match) - } - found = true - s.Type = match - case "created_at": - match := TeamResponseGrantExpandedPrincipal - if found && s.Type != match { - s.Type = "" - return errors.Errorf("multiple oneOf matches: (%v, %v)", s.Type, match) - } - found = true - s.Type = match - case "display": - // Type-based discrimination: check if field has expected JSON type - if typ := d.Next(); typ != jx.String { - // Field exists but has wrong type, not a match for this variant - return d.Skip() - } - match := UserGrantExpandedPrincipal - if found && s.Type != match { - s.Type = "" - return errors.Errorf("multiple oneOf matches: (%v, %v)", s.Type, match) - } - found = true - s.Type = match - case "identifier": - // Type-based discrimination: check if field has expected JSON type - if typ := d.Next(); typ != jx.String { - // Field exists but has wrong type, not a match for this variant - return d.Skip() - } - match := UserGrantExpandedPrincipal - if found && s.Type != match { - s.Type = "" - return errors.Errorf("multiple oneOf matches: (%v, %v)", s.Type, match) - } - found = true - s.Type = match - case "identifier_property": - // Type-based discrimination: check if field has expected JSON type - if typ := d.Next(); typ != jx.String { - // Field exists but has wrong type, not a match for this variant - return d.Skip() - } - match := UserGrantExpandedPrincipal - if found && s.Type != match { - s.Type = "" - return errors.Errorf("multiple oneOf matches: (%v, %v)", s.Type, match) - } - found = true - s.Type = match - case "metadata": - // Type-based discrimination: check if field has expected JSON type - if typ := d.Next(); typ != jx.Object { - // Field exists but has wrong type, not a match for this variant - return d.Skip() - } - match := UserGrantExpandedPrincipal - if found && s.Type != match { - s.Type = "" - return errors.Errorf("multiple oneOf matches: (%v, %v)", s.Type, match) - } - found = true - s.Type = match - case "name": - // Type-based discrimination: check if field has expected JSON type - if typ := d.Next(); typ != jx.String { - // Field exists but has wrong type, not a match for this variant - return d.Skip() - } - match := TeamResponseGrantExpandedPrincipal - if found && s.Type != match { - s.Type = "" - return errors.Errorf("multiple oneOf matches: (%v, %v)", s.Type, match) - } - found = true - s.Type = match - case "schema": - // Type-based discrimination: check if field has expected JSON type - if typ := d.Next(); typ != jx.String { - // Field exists but has wrong type, not a match for this variant - return d.Skip() - } - match := UserGrantExpandedPrincipal - if found && s.Type != match { - s.Type = "" - return errors.Errorf("multiple oneOf matches: (%v, %v)", s.Type, match) - } - found = true - s.Type = match - case "status": - // Type-based discrimination: check if field has expected JSON type - if typ := d.Next(); typ != jx.String { - // Field exists but has wrong type, not a match for this variant - return d.Skip() - } - match := TeamResponseGrantExpandedPrincipal - if found && s.Type != match { - s.Type = "" - return errors.Errorf("multiple oneOf matches: (%v, %v)", s.Type, match) - } - found = true - s.Type = match - case "teams": - // Type-based discrimination: check if field has expected JSON type - if typ := d.Next(); typ != jx.Array { - // Field exists but has wrong type, not a match for this variant - return d.Skip() - } - match := UserGrantExpandedPrincipal - if found && s.Type != match { - s.Type = "" - return errors.Errorf("multiple oneOf matches: (%v, %v)", s.Type, match) - } - found = true - s.Type = match - case "teams_truncated": - // Type-based discrimination: check if field has expected JSON type - if typ := d.Next(); typ != jx.Bool { - // Field exists but has wrong type, not a match for this variant - return d.Skip() - } - match := UserGrantExpandedPrincipal - if found && s.Type != match { - s.Type = "" - return errors.Errorf("multiple oneOf matches: (%v, %v)", s.Type, match) - } - found = true - s.Type = match - case "updated_at": - match := TeamResponseGrantExpandedPrincipal - if found && s.Type != match { - s.Type = "" - return errors.Errorf("multiple oneOf matches: (%v, %v)", s.Type, match) - } - found = true - s.Type = match - } - return d.Skip() - }) - }); err != nil { - return errors.Wrap(err, "capture") - } - if !found { - return errors.New("unable to detect sum type variant") - } - switch s.Type { - case UserGrantExpandedPrincipal: - if err := s.User.Decode(d); err != nil { - return err - } - case TeamResponseGrantExpandedPrincipal: - if err := s.TeamResponse.Decode(d); err != nil { - return err - } - default: - return errors.Errorf("inferred invalid type: %s", s.Type) - } - return nil -} - -// MarshalJSON implements stdjson.Marshaler. -func (s GrantExpandedPrincipal) MarshalJSON() ([]byte, error) { - e := jx.Encoder{} - s.Encode(&e) - return e.Bytes(), nil -} - -// UnmarshalJSON implements stdjson.Unmarshaler. -func (s *GrantExpandedPrincipal) UnmarshalJSON(data []byte) error { - d := jx.DecodeBytes(data) - return s.Decode(d) -} - // Encode encodes GrantFilterField as json. func (s GrantFilterField) Encode(e *jx.Encoder) { e.Str(string(s)) @@ -40519,10 +40261,10 @@ func (s *GrantFilterField) Decode(d *jx.Decoder) error { switch GrantFilterField(v) { case GrantFilterFieldCreatedAt: *s = GrantFilterFieldCreatedAt - case GrantFilterFieldPrincipalType: - *s = GrantFilterFieldPrincipalType - case GrantFilterFieldPrincipalID: - *s = GrantFilterFieldPrincipalID + case GrantFilterFieldUserID: + *s = GrantFilterFieldUserID + case GrantFilterFieldTeamID: + *s = GrantFilterFieldTeamID case GrantFilterFieldRelation: *s = GrantFilterFieldRelation case GrantFilterFieldExpiresAt: @@ -41337,46 +41079,6 @@ func (s *GrantPrincipalNotFoundDetails) UnmarshalJSON(data []byte) error { return s.Decode(d) } -// Encode encodes GrantPrincipalType as json. -func (s GrantPrincipalType) Encode(e *jx.Encoder) { - e.Str(string(s)) -} - -// Decode decodes GrantPrincipalType from json. -func (s *GrantPrincipalType) Decode(d *jx.Decoder) error { - if s == nil { - return errors.New("invalid: unable to decode GrantPrincipalType to nil") - } - v, err := d.StrBytes() - if err != nil { - return err - } - // Try to use constant string. - switch GrantPrincipalType(v) { - case GrantPrincipalTypeUser: - *s = GrantPrincipalTypeUser - case GrantPrincipalTypeTeam: - *s = GrantPrincipalTypeTeam - default: - *s = GrantPrincipalType(v) - } - - return nil -} - -// MarshalJSON implements stdjson.Marshaler. -func (s GrantPrincipalType) MarshalJSON() ([]byte, error) { - e := jx.Encoder{} - s.Encode(&e) - return e.Bytes(), nil -} - -// UnmarshalJSON implements stdjson.Unmarshaler. -func (s *GrantPrincipalType) UnmarshalJSON(data []byte) error { - d := jx.DecodeBytes(data) - return s.Decode(d) -} - // Encode encodes GrantRelation as json. func (s GrantRelation) Encode(e *jx.Encoder) { e.Str(string(s)) @@ -41461,6 +41163,424 @@ func (s *GrantSortingField) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode implements json.Marshaler. +func (s *GrantTeam) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *GrantTeam) encodeFields(e *jx.Encoder) { + { + e.FieldStart("team_id") + e.Str(s.TeamID) + } + { + if s.Name.Set { + e.FieldStart("name") + s.Name.Encode(e) + } + } + { + if s.Status.Set { + e.FieldStart("status") + s.Status.Encode(e) + } + } + { + if s.CreatedAt.Set { + e.FieldStart("created_at") + s.CreatedAt.Encode(e, json.EncodeDateTime) + } + } + { + if s.UpdatedAt.Set { + e.FieldStart("updated_at") + s.UpdatedAt.Encode(e, json.EncodeDateTime) + } + } +} + +var jsonFieldsNameOfGrantTeam = [5]string{ + 0: "team_id", + 1: "name", + 2: "status", + 3: "created_at", + 4: "updated_at", +} + +// Decode decodes GrantTeam from json. +func (s *GrantTeam) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode GrantTeam to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "team_id": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.TeamID = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"team_id\"") + } + case "name": + if err := func() error { + s.Name.Reset() + if err := s.Name.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"name\"") + } + case "status": + if err := func() error { + s.Status.Reset() + if err := s.Status.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"status\"") + } + case "created_at": + if err := func() error { + s.CreatedAt.Reset() + if err := s.CreatedAt.Decode(d, json.DecodeDateTime); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"created_at\"") + } + case "updated_at": + if err := func() error { + s.UpdatedAt.Reset() + if err := s.UpdatedAt.Decode(d, json.DecodeDateTime); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"updated_at\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode GrantTeam") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000001, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfGrantTeam) { + name = jsonFieldsNameOfGrantTeam[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *GrantTeam) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *GrantTeam) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *GrantUser) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *GrantUser) encodeFields(e *jx.Encoder) { + { + e.FieldStart("user_id") + s.UserID.Encode(e) + } + { + if s.Identifier.Set { + e.FieldStart("identifier") + s.Identifier.Encode(e) + } + } + { + if s.IdentifierProperty.Set { + e.FieldStart("identifier_property") + s.IdentifierProperty.Encode(e) + } + } + { + if s.Display.Set { + e.FieldStart("display") + s.Display.Encode(e) + } + } + { + if s.Schema.Set { + e.FieldStart("schema") + s.Schema.Encode(e) + } + } + { + if s.Attributes.Set { + e.FieldStart("attributes") + s.Attributes.Encode(e) + } + } + { + if s.Metadata.Set { + e.FieldStart("metadata") + s.Metadata.Encode(e) + } + } +} + +var jsonFieldsNameOfGrantUser = [7]string{ + 0: "user_id", + 1: "identifier", + 2: "identifier_property", + 3: "display", + 4: "schema", + 5: "attributes", + 6: "metadata", +} + +// Decode decodes GrantUser from json. +func (s *GrantUser) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode GrantUser to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "user_id": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + if err := s.UserID.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"user_id\"") + } + case "identifier": + if err := func() error { + s.Identifier.Reset() + if err := s.Identifier.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"identifier\"") + } + case "identifier_property": + if err := func() error { + s.IdentifierProperty.Reset() + if err := s.IdentifierProperty.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"identifier_property\"") + } + case "display": + if err := func() error { + s.Display.Reset() + if err := s.Display.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"display\"") + } + case "schema": + if err := func() error { + s.Schema.Reset() + if err := s.Schema.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"schema\"") + } + case "attributes": + if err := func() error { + s.Attributes.Reset() + if err := s.Attributes.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"attributes\"") + } + case "metadata": + if err := func() error { + s.Metadata.Reset() + if err := s.Metadata.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"metadata\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode GrantUser") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000001, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfGrantUser) { + name = jsonFieldsNameOfGrantUser[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *GrantUser) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *GrantUser) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s GrantUserAttributes) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields implements json.Marshaler. +func (s GrantUserAttributes) encodeFields(e *jx.Encoder) { + for k, elem := range s { + e.FieldStart(k) + + if len(elem) != 0 { + e.Raw(elem) + } + } +} + +// Decode decodes GrantUserAttributes from json. +func (s *GrantUserAttributes) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode GrantUserAttributes to nil") + } + m := s.init() + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + var elem jx.Raw + if err := func() error { + v, err := d.RawAppend(nil) + elem = jx.Raw(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrapf(err, "decode field %q", k) + } + m[string(k)] = elem + return nil + }); err != nil { + return errors.Wrap(err, "decode GrantUserAttributes") + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s GrantUserAttributes) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *GrantUserAttributes) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode implements json.Marshaler. func (s *HandoffResponse) Encode(e *jx.Encoder) { e.ObjStart() @@ -48587,6 +48707,106 @@ func (s *OptGrantPrincipalNotFoundDetails) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode encodes GrantTeam as json. +func (o OptGrantTeam) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes GrantTeam from json. +func (o *OptGrantTeam) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptGrantTeam to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptGrantTeam) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptGrantTeam) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes GrantUser as json. +func (o OptGrantUser) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes GrantUser from json. +func (o *OptGrantUser) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptGrantUser to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptGrantUser) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptGrantUser) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes GrantUserAttributes as json. +func (o OptGrantUserAttributes) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes GrantUserAttributes from json. +func (o *OptGrantUserAttributes) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptGrantUserAttributes to nil") + } + o.Set = true + o.Value = make(GrantUserAttributes) + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptGrantUserAttributes) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptGrantUserAttributes) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode encodes int as json. func (o OptInt) Encode(e *jx.Encoder) { if !o.Set { @@ -49639,55 +49859,6 @@ func (s *OptNilFlowdefUpdatedEventActorType) UnmarshalJSON(data []byte) error { return s.Decode(d) } -// Encode encodes GrantExpandedPrincipal as json. -func (o OptNilGrantExpandedPrincipal) Encode(e *jx.Encoder) { - if !o.Set { - return - } - if o.Null { - e.Null() - return - } - o.Value.Encode(e) -} - -// Decode decodes GrantExpandedPrincipal from json. -func (o *OptNilGrantExpandedPrincipal) Decode(d *jx.Decoder) error { - if o == nil { - return errors.New("invalid: unable to decode OptNilGrantExpandedPrincipal to nil") - } - if d.Next() == jx.Null { - if err := d.Null(); err != nil { - return err - } - - var v GrantExpandedPrincipal - o.Value = v - o.Set = true - o.Null = true - return nil - } - o.Set = true - o.Null = false - if err := o.Value.Decode(d); err != nil { - return err - } - return nil -} - -// MarshalJSON implements stdjson.Marshaler. -func (s OptNilGrantExpandedPrincipal) MarshalJSON() ([]byte, error) { - e := jx.Encoder{} - s.Encode(&e) - return e.Bytes(), nil -} - -// UnmarshalJSON implements stdjson.Unmarshaler. -func (s *OptNilGrantExpandedPrincipal) UnmarshalJSON(data []byte) error { - d := jx.DecodeBytes(data) - return s.Decode(d) -} - // Encode encodes PageToken as json. func (o OptNilPageToken) Encode(e *jx.Encoder) { if !o.Set { @@ -52174,6 +52345,39 @@ func (s *OptTeamID) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode encodes TeamLocator as json. +func (o OptTeamLocator) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes TeamLocator from json. +func (o *OptTeamLocator) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptTeamLocator to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptTeamLocator) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptTeamLocator) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode encodes TeamPermissionDeniedDetails as json. func (o OptTeamPermissionDeniedDetails) Encode(e *jx.Encoder) { if !o.Set { @@ -52208,18 +52412,18 @@ func (s *OptTeamPermissionDeniedDetails) UnmarshalJSON(data []byte) error { return s.Decode(d) } -// Encode encodes TeamRef as json. -func (o OptTeamRef) Encode(e *jx.Encoder) { +// Encode encodes TeamStatus as json. +func (o OptTeamStatus) Encode(e *jx.Encoder) { if !o.Set { return } - o.Value.Encode(e) + e.Str(string(o.Value)) } -// Decode decodes TeamRef from json. -func (o *OptTeamRef) Decode(d *jx.Decoder) error { +// Decode decodes TeamStatus from json. +func (o *OptTeamStatus) Decode(d *jx.Decoder) error { if o == nil { - return errors.New("invalid: unable to decode OptTeamRef to nil") + return errors.New("invalid: unable to decode OptTeamStatus to nil") } o.Set = true if err := o.Value.Decode(d); err != nil { @@ -52229,14 +52433,14 @@ func (o *OptTeamRef) Decode(d *jx.Decoder) error { } // MarshalJSON implements stdjson.Marshaler. -func (s OptTeamRef) MarshalJSON() ([]byte, error) { +func (s OptTeamStatus) MarshalJSON() ([]byte, error) { e := jx.Encoder{} s.Encode(&e) return e.Bytes(), nil } // UnmarshalJSON implements stdjson.Unmarshaler. -func (s *OptTeamRef) UnmarshalJSON(data []byte) error { +func (s *OptTeamStatus) UnmarshalJSON(data []byte) error { d := jx.DecodeBytes(data) return s.Decode(d) } @@ -52578,6 +52782,39 @@ func (s *OptUserDeletedEventDelegationType) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode encodes UserID as json. +func (o OptUserID) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes UserID from json. +func (o *OptUserID) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptUserID to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptUserID) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptUserID) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode encodes UserInvalidDetails as json. func (o OptUserInvalidDetails) Encode(e *jx.Encoder) { if !o.Set { @@ -52612,6 +52849,72 @@ func (s *OptUserInvalidDetails) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode encodes UserLocator as json. +func (o OptUserLocator) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes UserLocator from json. +func (o *OptUserLocator) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptUserLocator to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptUserLocator) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptUserLocator) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes UserMetadata as json. +func (o OptUserMetadata) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes UserMetadata from json. +func (o *OptUserMetadata) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptUserMetadata to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptUserMetadata) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptUserMetadata) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode encodes UserNotFoundDetails as json. func (o OptUserNotFoundDetails) Encode(e *jx.Encoder) { if !o.Set { @@ -72540,6 +72843,86 @@ func (s *TeamID) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode implements json.Marshaler. +func (s *TeamLocator) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *TeamLocator) encodeFields(e *jx.Encoder) { + { + if s.TeamID.Set { + e.FieldStart("team_id") + s.TeamID.Encode(e) + } + } + { + if s.Name.Set { + e.FieldStart("name") + s.Name.Encode(e) + } + } +} + +var jsonFieldsNameOfTeamLocator = [2]string{ + 0: "team_id", + 1: "name", +} + +// Decode decodes TeamLocator from json. +func (s *TeamLocator) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode TeamLocator to nil") + } + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "team_id": + if err := func() error { + s.TeamID.Reset() + if err := s.TeamID.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"team_id\"") + } + case "name": + if err := func() error { + s.Name.Reset() + if err := s.Name.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"name\"") + } + default: + return errors.Errorf("unexpected field %q", k) + } + return nil + }); err != nil { + return errors.Wrap(err, "decode TeamLocator") + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *TeamLocator) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *TeamLocator) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode implements json.Marshaler. func (s *TeamPayload) Encode(e *jx.Encoder) { e.ObjStart() @@ -72791,119 +73174,6 @@ func (s *TeamPermissionDeniedDetails) UnmarshalJSON(data []byte) error { return s.Decode(d) } -// Encode implements json.Marshaler. -func (s *TeamRef) Encode(e *jx.Encoder) { - e.ObjStart() - s.encodeFields(e) - e.ObjEnd() -} - -// encodeFields encodes fields. -func (s *TeamRef) encodeFields(e *jx.Encoder) { - { - e.FieldStart("team_id") - e.Str(s.TeamID) - } - { - if s.Name.Set { - e.FieldStart("name") - s.Name.Encode(e) - } - } -} - -var jsonFieldsNameOfTeamRef = [2]string{ - 0: "team_id", - 1: "name", -} - -// Decode decodes TeamRef from json. -func (s *TeamRef) Decode(d *jx.Decoder) error { - if s == nil { - return errors.New("invalid: unable to decode TeamRef to nil") - } - var requiredBitSet [1]uint8 - - if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { - switch string(k) { - case "team_id": - requiredBitSet[0] |= 1 << 0 - if err := func() error { - v, err := d.Str() - s.TeamID = string(v) - if err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "decode field \"team_id\"") - } - case "name": - if err := func() error { - s.Name.Reset() - if err := s.Name.Decode(d); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "decode field \"name\"") - } - default: - return d.Skip() - } - return nil - }); err != nil { - return errors.Wrap(err, "decode TeamRef") - } - // Validate required fields. - var failures []validate.FieldError - for i, mask := range [1]uint8{ - 0b00000001, - } { - if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { - // Mask only required fields and check equality to mask using XOR. - // - // If XOR result is not zero, result is not equal to expected, so some fields are missed. - // Bits of fields which would be set are actually bits of missed fields. - missed := bits.OnesCount8(result) - for bitN := 0; bitN < missed; bitN++ { - bitIdx := bits.TrailingZeros8(result) - fieldIdx := i*8 + bitIdx - var name string - if fieldIdx < len(jsonFieldsNameOfTeamRef) { - name = jsonFieldsNameOfTeamRef[fieldIdx] - } else { - name = strconv.Itoa(fieldIdx) - } - failures = append(failures, validate.FieldError{ - Name: name, - Error: validate.ErrFieldRequired, - }) - // Reset bit. - result &^= 1 << bitIdx - } - } - } - if len(failures) > 0 { - return &validate.Error{Fields: failures} - } - - return nil -} - -// MarshalJSON implements stdjson.Marshaler. -func (s *TeamRef) MarshalJSON() ([]byte, error) { - e := jx.Encoder{} - s.Encode(&e) - return e.Bytes(), nil -} - -// UnmarshalJSON implements stdjson.Unmarshaler. -func (s *TeamRef) UnmarshalJSON(data []byte) error { - d := jx.DecodeBytes(data) - return s.Decode(d) -} - // Encode implements json.Marshaler. func (s *TeamResponse) Encode(e *jx.Encoder) { e.ObjStart() @@ -77714,6 +77984,86 @@ func (s *UserInvalidDetails) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode implements json.Marshaler. +func (s *UserLocator) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *UserLocator) encodeFields(e *jx.Encoder) { + { + if s.UserID.Set { + e.FieldStart("user_id") + s.UserID.Encode(e) + } + } + { + if s.Identifier.Set { + e.FieldStart("identifier") + s.Identifier.Encode(e) + } + } +} + +var jsonFieldsNameOfUserLocator = [2]string{ + 0: "user_id", + 1: "identifier", +} + +// Decode decodes UserLocator from json. +func (s *UserLocator) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode UserLocator to nil") + } + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "user_id": + if err := func() error { + s.UserID.Reset() + if err := s.UserID.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"user_id\"") + } + case "identifier": + if err := func() error { + s.Identifier.Reset() + if err := s.Identifier.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"identifier\"") + } + default: + return errors.Errorf("unexpected field %q", k) + } + return nil + }); err != nil { + return errors.Wrap(err, "decode UserLocator") + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *UserLocator) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *UserLocator) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode implements json.Marshaler. func (s *UserMetadata) Encode(e *jx.Encoder) { e.ObjStart() diff --git a/api/generated/oas_parameters_gen.go b/api/generated/oas_parameters_gen.go index 297d574ea..719c2212f 100644 --- a/api/generated/oas_parameters_gen.go +++ b/api/generated/oas_parameters_gen.go @@ -2405,6 +2405,9 @@ type GetGrantParams struct { ID string // The unique identifier of the project. ProjectID ProjectID + // Extra principal fields to add onto `user` or `team`. Same enum and + // `user.read` + `team.read` gate as `POST /grants/query`. + Expand []GrantExpand `json:",omitempty"` } func unpackGetGrantParams(packed middleware.Parameters) (params GetGrantParams) { @@ -2422,6 +2425,15 @@ func unpackGetGrantParams(packed middleware.Parameters) (params GetGrantParams) } params.ProjectID = packed[key].(ProjectID) } + { + key := middleware.ParameterKey{ + Name: "expand", + In: "query", + } + if v, ok := packed[key]; ok { + params.Expand = v.([]GrantExpand) + } + } return params } @@ -2523,6 +2535,85 @@ func decodeGetGrantParams(args [1]string, argsEscaped bool, r *http.Request) (pa Err: err, } } + // Decode query: expand. + if err := func() error { + cfg := uri.QueryParameterDecodingConfig{ + Name: "expand", + Style: uri.QueryStyleForm, + Explode: true, + } + + if err := q.HasParam(cfg); err == nil { + if err := q.DecodeParam(cfg, func(d uri.Decoder) error { + return d.DecodeArray(func(d uri.Decoder) error { + var paramsDotExpandVal GrantExpand + if err := func() error { + val, err := d.DecodeValue() + if err != nil { + return err + } + + c, err := conv.ToString(val) + if err != nil { + return err + } + + paramsDotExpandVal = GrantExpand(c) + return nil + }(); err != nil { + return err + } + params.Expand = append(params.Expand, paramsDotExpandVal) + return nil + }) + }); err != nil { + return err + } + if err := func() error { + if params.Expand == nil { + return nil // optional + } + if err := (validate.Array{ + MinLength: 0, + MinLengthSet: false, + MaxLength: 0, + MaxLengthSet: false, + }).ValidateLength(len(params.Expand)); err != nil { + return errors.Wrap(err, "array") + } + if err := validate.UniqueItems(params.Expand); err != nil { + return errors.Wrap(err, "array") + } + var failures []validate.FieldError + for i, elem := range params.Expand { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + return params, &ogenerrors.DecodeParamError{ + Name: "expand", + In: "query", + Err: err, + } + } return params, nil } diff --git a/api/generated/oas_schemas_gen.go b/api/generated/oas_schemas_gen.go index 525e40f00..cdaafbe1c 100644 --- a/api/generated/oas_schemas_gen.go +++ b/api/generated/oas_schemas_gen.go @@ -9624,29 +9624,25 @@ type CreateGrantNotFound ErrorDetails func (*CreateGrantNotFound) createGrantRes() {} +// Bind a user or a team. Exactly one of `user` or `team`. Shared fields are +// `relation` and optional `expires_at`. Sending both locators or neither is +// `grant.invalid`. // Ref: # type CreateGrantRequest struct { - // Kind of principal to bind. `sk_proj` and owning-team grants are not accepted. - PrincipalType CreateGrantRequestPrincipalType `json:"principal_type"` - // Principal id (`user_` or `team_`). The principal must - // exist in the platform project when a platform project is configured. - PrincipalID string `json:"principal_id"` // Catalog relation on `object_type` `project`. `team` (owning-team) is not allowed. Relation CreateGrantRequestRelation `json:"relation"` // Optional expiry. Must be in the future when set. Expiry stops // authorization but does not free the unique binding; DELETE the grant // before posting the same principal and relation again. ExpiresAt OptDateTime `json:"expires_at"` -} - -// GetPrincipalType returns the value of PrincipalType. -func (s *CreateGrantRequest) GetPrincipalType() CreateGrantRequestPrincipalType { - return s.PrincipalType -} - -// GetPrincipalID returns the value of PrincipalID. -func (s *CreateGrantRequest) GetPrincipalID() string { - return s.PrincipalID + // User to bind. Exactly one of `user_id` or `identifier`. The user must + // exist in the platform project when a platform project is configured. + // Mutually exclusive with `team`. + User OptUserLocator `json:"user"` + // Team to bind. Exactly one of `team_id` or `name`. The team must exist + // in the platform project when a platform project is configured. + // Mutually exclusive with `user`. + Team OptTeamLocator `json:"team"` } // GetRelation returns the value of Relation. @@ -9659,14 +9655,14 @@ func (s *CreateGrantRequest) GetExpiresAt() OptDateTime { return s.ExpiresAt } -// SetPrincipalType sets the value of PrincipalType. -func (s *CreateGrantRequest) SetPrincipalType(val CreateGrantRequestPrincipalType) { - s.PrincipalType = val +// GetUser returns the value of User. +func (s *CreateGrantRequest) GetUser() OptUserLocator { + return s.User } -// SetPrincipalID sets the value of PrincipalID. -func (s *CreateGrantRequest) SetPrincipalID(val string) { - s.PrincipalID = val +// GetTeam returns the value of Team. +func (s *CreateGrantRequest) GetTeam() OptTeamLocator { + return s.Team } // SetRelation sets the value of Relation. @@ -9679,46 +9675,14 @@ func (s *CreateGrantRequest) SetExpiresAt(val OptDateTime) { s.ExpiresAt = val } -// Kind of principal to bind. `sk_proj` and owning-team grants are not accepted. -type CreateGrantRequestPrincipalType string - -const ( - CreateGrantRequestPrincipalTypeUser CreateGrantRequestPrincipalType = "user" - CreateGrantRequestPrincipalTypeTeam CreateGrantRequestPrincipalType = "team" -) - -// AllValues returns all CreateGrantRequestPrincipalType values. -func (CreateGrantRequestPrincipalType) AllValues() []CreateGrantRequestPrincipalType { - return []CreateGrantRequestPrincipalType{ - CreateGrantRequestPrincipalTypeUser, - CreateGrantRequestPrincipalTypeTeam, - } -} - -// MarshalText implements encoding.TextMarshaler. -func (s CreateGrantRequestPrincipalType) MarshalText() ([]byte, error) { - switch s { - case CreateGrantRequestPrincipalTypeUser: - return []byte(s), nil - case CreateGrantRequestPrincipalTypeTeam: - return []byte(s), nil - default: - return nil, errors.Errorf("invalid value: %q", s) - } +// SetUser sets the value of User. +func (s *CreateGrantRequest) SetUser(val OptUserLocator) { + s.User = val } -// UnmarshalText implements encoding.TextUnmarshaler. -func (s *CreateGrantRequestPrincipalType) UnmarshalText(data []byte) error { - switch CreateGrantRequestPrincipalType(data) { - case CreateGrantRequestPrincipalTypeUser: - *s = CreateGrantRequestPrincipalTypeUser - return nil - case CreateGrantRequestPrincipalTypeTeam: - *s = CreateGrantRequestPrincipalTypeTeam - return nil - default: - return errors.Errorf("invalid value: %q", data) - } +// SetTeam sets the value of Team. +func (s *CreateGrantRequest) SetTeam(val OptTeamLocator) { + s.Team = val } // Catalog relation on `object_type` `project`. `team` (owning-team) is not allowed. @@ -20730,6 +20694,8 @@ type GetGrantErrorResponse struct { GrantPermissionDenied GrantPermissionDenied Internal Internal ReqInvalid ReqInvalid + TeamPermissionDenied TeamPermissionDenied + UserPermissionDenied UserPermissionDenied } // GetGrantErrorResponseType is oneOf type of GetGrantErrorResponse. @@ -20742,6 +20708,8 @@ const ( GrantPermissionDeniedGetGrantErrorResponse GetGrantErrorResponseType = "grant.permission_denied" InternalGetGrantErrorResponse GetGrantErrorResponseType = "internal" ReqInvalidGetGrantErrorResponse GetGrantErrorResponseType = "req.invalid" + TeamPermissionDeniedGetGrantErrorResponse GetGrantErrorResponseType = "team.permission_denied" + UserPermissionDeniedGetGrantErrorResponse GetGrantErrorResponseType = "user.permission_denied" ) // IsAuthUnauthorized reports whether GetGrantErrorResponse is AuthUnauthorized. @@ -20765,6 +20733,16 @@ func (s GetGrantErrorResponse) IsInternal() bool { return s.Type == InternalGetG // IsReqInvalid reports whether GetGrantErrorResponse is ReqInvalid. func (s GetGrantErrorResponse) IsReqInvalid() bool { return s.Type == ReqInvalidGetGrantErrorResponse } +// IsTeamPermissionDenied reports whether GetGrantErrorResponse is TeamPermissionDenied. +func (s GetGrantErrorResponse) IsTeamPermissionDenied() bool { + return s.Type == TeamPermissionDeniedGetGrantErrorResponse +} + +// IsUserPermissionDenied reports whether GetGrantErrorResponse is UserPermissionDenied. +func (s GetGrantErrorResponse) IsUserPermissionDenied() bool { + return s.Type == UserPermissionDeniedGetGrantErrorResponse +} + // SetAuthUnauthorized sets GetGrantErrorResponse to AuthUnauthorized. func (s *GetGrantErrorResponse) SetAuthUnauthorized(v AuthUnauthorized) { s.Type = AuthUnauthorizedGetGrantErrorResponse @@ -20870,6 +20848,48 @@ func NewReqInvalidGetGrantErrorResponse(v ReqInvalid) GetGrantErrorResponse { return s } +// SetTeamPermissionDenied sets GetGrantErrorResponse to TeamPermissionDenied. +func (s *GetGrantErrorResponse) SetTeamPermissionDenied(v TeamPermissionDenied) { + s.Type = TeamPermissionDeniedGetGrantErrorResponse + s.TeamPermissionDenied = v +} + +// GetTeamPermissionDenied returns TeamPermissionDenied and true boolean if GetGrantErrorResponse is TeamPermissionDenied. +func (s GetGrantErrorResponse) GetTeamPermissionDenied() (v TeamPermissionDenied, ok bool) { + if !s.IsTeamPermissionDenied() { + return v, false + } + return s.TeamPermissionDenied, true +} + +// NewTeamPermissionDeniedGetGrantErrorResponse returns new GetGrantErrorResponse from TeamPermissionDenied. +func NewTeamPermissionDeniedGetGrantErrorResponse(v TeamPermissionDenied) GetGrantErrorResponse { + var s GetGrantErrorResponse + s.SetTeamPermissionDenied(v) + return s +} + +// SetUserPermissionDenied sets GetGrantErrorResponse to UserPermissionDenied. +func (s *GetGrantErrorResponse) SetUserPermissionDenied(v UserPermissionDenied) { + s.Type = UserPermissionDeniedGetGrantErrorResponse + s.UserPermissionDenied = v +} + +// GetUserPermissionDenied returns UserPermissionDenied and true boolean if GetGrantErrorResponse is UserPermissionDenied. +func (s GetGrantErrorResponse) GetUserPermissionDenied() (v UserPermissionDenied, ok bool) { + if !s.IsUserPermissionDenied() { + return v, false + } + return s.UserPermissionDenied, true +} + +// NewUserPermissionDeniedGetGrantErrorResponse returns new GetGrantErrorResponse from UserPermissionDenied. +func NewUserPermissionDeniedGetGrantErrorResponse(v UserPermissionDenied) GetGrantErrorResponse { + var s GetGrantErrorResponse + s.SetUserPermissionDenied(v) + return s +} + // GetGrantErrorResponseStatusCode wraps GetGrantErrorResponse with StatusCode. type GetGrantErrorResponseStatusCode struct { StatusCode int @@ -22048,17 +22068,16 @@ type GetUserByIDUnauthorized ErrorDetails func (*GetUserByIDUnauthorized) getUserByIDRes() {} -// A collaboration grant binding a principal to a project relation. +// A collaboration grant binding a user or a team to a project relation. +// Discriminate on which of `user` or `team` is present. Those objects are +// always refs (`user_id` / `team_id`); expand adds extra fields on the same +// object rather than a sibling. // Ref: # type Grant struct { // Managed assignment id (`asgn_`). ID string `json:"id"` // Project this grant is scoped to. ProjectID string `json:"project_id"` - // Kind of principal bound by this grant. - PrincipalType GrantPrincipalType `json:"principal_type"` - // Principal id (`user_` or `team_`). - PrincipalID string `json:"principal_id"` // Catalog object type. Always `project` for this API. ObjectType GrantObjectType `json:"object_type"` // Catalog relation on the project. @@ -22068,26 +22087,12 @@ type Grant struct { // When the grant expires. Null when it does not expire. GET still // returns expired unrevoked grants; authorization ignores them. ExpiresAt OptNilDateTime `json:"expires_at"` - // Resolved identity of a user principal (ADR 058). Present when - // `principal_type` is `user`; omitted for team grants. Degrades to - // `user_id` only when the user can no longer be loaded. - User OptUserRef `json:"user"` - // Resolved identity of a team principal. Present when `principal_type` - // is `team`; omitted for user grants. Degrades to `team_id` only when - // the team can no longer be loaded. This is a label ref, not the full - // Team body. - Team OptTeamRef `json:"team"` - // The principal named by `principal_id`, present only when the request - // asked for it with `expand: ["principal"]` (ADR 059). Absent means it - // was not requested; `null` means the principal cannot be loaded - // (deleted or missing). GET and create never set this field. - // When present, the body is the same representation `GET /users/{id}` - // serves for `principal_type=user`, or `GET /teams/{id}` for - // `principal_type=team`. Discriminate with the grant's existing - // `principal_type`. - // Requires `user.read` and `team.read` in addition to `project.read`. - // Both are checked on the whole request before the list. - Principal OptNilGrantExpandedPrincipal `json:"principal"` + // Present on a user grant. Omit on a team grant. Always a user-ref + // (`user_id`); expand adds envelope fields on this same object. + User OptGrantUser `json:"user"` + // Present on a team grant. Omit on a user grant. Always a team-ref + // (`team_id`); expand adds envelope fields on this same object. + Team OptGrantTeam `json:"team"` } // GetID returns the value of ID. @@ -22100,16 +22105,6 @@ func (s *Grant) GetProjectID() string { return s.ProjectID } -// GetPrincipalType returns the value of PrincipalType. -func (s *Grant) GetPrincipalType() GrantPrincipalType { - return s.PrincipalType -} - -// GetPrincipalID returns the value of PrincipalID. -func (s *Grant) GetPrincipalID() string { - return s.PrincipalID -} - // GetObjectType returns the value of ObjectType. func (s *Grant) GetObjectType() GrantObjectType { return s.ObjectType @@ -22131,20 +22126,15 @@ func (s *Grant) GetExpiresAt() OptNilDateTime { } // GetUser returns the value of User. -func (s *Grant) GetUser() OptUserRef { +func (s *Grant) GetUser() OptGrantUser { return s.User } // GetTeam returns the value of Team. -func (s *Grant) GetTeam() OptTeamRef { +func (s *Grant) GetTeam() OptGrantTeam { return s.Team } -// GetPrincipal returns the value of Principal. -func (s *Grant) GetPrincipal() OptNilGrantExpandedPrincipal { - return s.Principal -} - // SetID sets the value of ID. func (s *Grant) SetID(val string) { s.ID = val @@ -22155,16 +22145,6 @@ func (s *Grant) SetProjectID(val string) { s.ProjectID = val } -// SetPrincipalType sets the value of PrincipalType. -func (s *Grant) SetPrincipalType(val GrantPrincipalType) { - s.PrincipalType = val -} - -// SetPrincipalID sets the value of PrincipalID. -func (s *Grant) SetPrincipalID(val string) { - s.PrincipalID = val -} - // SetObjectType sets the value of ObjectType. func (s *Grant) SetObjectType(val GrantObjectType) { s.ObjectType = val @@ -22186,20 +22166,15 @@ func (s *Grant) SetExpiresAt(val OptNilDateTime) { } // SetUser sets the value of User. -func (s *Grant) SetUser(val OptUserRef) { +func (s *Grant) SetUser(val OptGrantUser) { s.User = val } // SetTeam sets the value of Team. -func (s *Grant) SetTeam(val OptTeamRef) { +func (s *Grant) SetTeam(val OptGrantTeam) { s.Team = val } -// SetPrincipal sets the value of Principal. -func (s *Grant) SetPrincipal(val OptNilGrantExpandedPrincipal) { - s.Principal = val -} - func (*Grant) createGrantRes() {} func (*Grant) getGrantRes() {} @@ -22256,17 +22231,17 @@ func (s *GrantAlreadyExistsDetails) init() GrantAlreadyExistsDetails { return m } -// A related object to embed on each returned grant (ADR 059). -// - `principal`: the principal named by `principal_id`, as `principal` on -// each grant. The property is omitted entirely when not requested. When -// requested, it is the same body `GET /users/{id}` serves for -// `principal_type=user`, or `GET /teams/{id}` for `principal_type=team`, -// and `null` when that principal cannot be loaded. Discriminate with the -// grant's existing `principal_type`. +// Expand the bound user or team on each returned grant (ADR 059, grant +// exception: extras are inlined onto `user` / `team`, not a sibling). +// - `principal`: add User envelope fields (`schema`, `attributes`, +// `metadata`) on `user`, or Team `status` / `created_at` / `updated_at` +// on `team`. The ref (`user_id` / `team_id`) is always present. When the +// principal cannot be loaded, the object stays a degraded ref — the same +// shape as not expanding. // Requires `user.read` and `team.read` in addition to `project.read`. // Both are checked on the whole request before the list, because a mixed // page is the common case. A caller who may not read either resource -// receives 403 rather than a silently missing `principal`. +// receives 403 rather than silently omitting extras. // Ref: # type GrantExpand string @@ -22302,100 +22277,29 @@ func (s *GrantExpand) UnmarshalText(data []byte) error { } } -// The principal bound by a grant: the User body when `principal_type` is -// `user`, or the Team body when `principal_type` is `team`. Same -// representation as `GET /users/{id}` and `GET /teams/{id}` respectively. -// Clients discriminate with the grant's existing `principal_type`. -// Ref: # -// GrantExpandedPrincipal represents sum type. -type GrantExpandedPrincipal struct { - Type GrantExpandedPrincipalType // switch on this field - User User - TeamResponse TeamResponse -} - -// GrantExpandedPrincipalType is oneOf type of GrantExpandedPrincipal. -type GrantExpandedPrincipalType string - -// Possible values for GrantExpandedPrincipalType. -const ( - UserGrantExpandedPrincipal GrantExpandedPrincipalType = "User" - TeamResponseGrantExpandedPrincipal GrantExpandedPrincipalType = "TeamResponse" -) - -// IsUser reports whether GrantExpandedPrincipal is User. -func (s GrantExpandedPrincipal) IsUser() bool { return s.Type == UserGrantExpandedPrincipal } - -// IsTeamResponse reports whether GrantExpandedPrincipal is TeamResponse. -func (s GrantExpandedPrincipal) IsTeamResponse() bool { - return s.Type == TeamResponseGrantExpandedPrincipal -} - -// SetUser sets GrantExpandedPrincipal to User. -func (s *GrantExpandedPrincipal) SetUser(v User) { - s.Type = UserGrantExpandedPrincipal - s.User = v -} - -// GetUser returns User and true boolean if GrantExpandedPrincipal is User. -func (s GrantExpandedPrincipal) GetUser() (v User, ok bool) { - if !s.IsUser() { - return v, false - } - return s.User, true -} - -// NewUserGrantExpandedPrincipal returns new GrantExpandedPrincipal from User. -func NewUserGrantExpandedPrincipal(v User) GrantExpandedPrincipal { - var s GrantExpandedPrincipal - s.SetUser(v) - return s -} - -// SetTeamResponse sets GrantExpandedPrincipal to TeamResponse. -func (s *GrantExpandedPrincipal) SetTeamResponse(v TeamResponse) { - s.Type = TeamResponseGrantExpandedPrincipal - s.TeamResponse = v -} - -// GetTeamResponse returns TeamResponse and true boolean if GrantExpandedPrincipal is TeamResponse. -func (s GrantExpandedPrincipal) GetTeamResponse() (v TeamResponse, ok bool) { - if !s.IsTeamResponse() { - return v, false - } - return s.TeamResponse, true -} - -// NewTeamResponseGrantExpandedPrincipal returns new GrantExpandedPrincipal from TeamResponse. -func NewTeamResponseGrantExpandedPrincipal(v TeamResponse) GrantExpandedPrincipal { - var s GrantExpandedPrincipal - s.SetTeamResponse(v) - return s -} - // Field to filter grants by: // - `created_at`: RFC3339 timestamp -// - `principal_type`: `user` or `team` -// - `principal_id`: principal id (`user_` or `team_`) +// - `user_id`: user principal id (`user_`) +// - `team_id`: team principal id (`team_`) // - `relation`: `viewer`, `editor`, or `admin` // - `expires_at`: RFC3339 timestamp (null when the grant does not expire). // Ref: # type GrantFilterField string const ( - GrantFilterFieldCreatedAt GrantFilterField = "created_at" - GrantFilterFieldPrincipalType GrantFilterField = "principal_type" - GrantFilterFieldPrincipalID GrantFilterField = "principal_id" - GrantFilterFieldRelation GrantFilterField = "relation" - GrantFilterFieldExpiresAt GrantFilterField = "expires_at" + GrantFilterFieldCreatedAt GrantFilterField = "created_at" + GrantFilterFieldUserID GrantFilterField = "user_id" + GrantFilterFieldTeamID GrantFilterField = "team_id" + GrantFilterFieldRelation GrantFilterField = "relation" + GrantFilterFieldExpiresAt GrantFilterField = "expires_at" ) // AllValues returns all GrantFilterField values. func (GrantFilterField) AllValues() []GrantFilterField { return []GrantFilterField{ GrantFilterFieldCreatedAt, - GrantFilterFieldPrincipalType, - GrantFilterFieldPrincipalID, + GrantFilterFieldUserID, + GrantFilterFieldTeamID, GrantFilterFieldRelation, GrantFilterFieldExpiresAt, } @@ -22406,9 +22310,9 @@ func (s GrantFilterField) MarshalText() ([]byte, error) { switch s { case GrantFilterFieldCreatedAt: return []byte(s), nil - case GrantFilterFieldPrincipalType: + case GrantFilterFieldUserID: return []byte(s), nil - case GrantFilterFieldPrincipalID: + case GrantFilterFieldTeamID: return []byte(s), nil case GrantFilterFieldRelation: return []byte(s), nil @@ -22425,11 +22329,11 @@ func (s *GrantFilterField) UnmarshalText(data []byte) error { case GrantFilterFieldCreatedAt: *s = GrantFilterFieldCreatedAt return nil - case GrantFilterFieldPrincipalType: - *s = GrantFilterFieldPrincipalType + case GrantFilterFieldUserID: + *s = GrantFilterFieldUserID return nil - case GrantFilterFieldPrincipalID: - *s = GrantFilterFieldPrincipalID + case GrantFilterFieldTeamID: + *s = GrantFilterFieldTeamID return nil case GrantFilterFieldRelation: *s = GrantFilterFieldRelation @@ -22689,48 +22593,6 @@ func (s *GrantPrincipalNotFoundDetails) init() GrantPrincipalNotFoundDetails { return m } -// Kind of principal bound by this grant. -type GrantPrincipalType string - -const ( - GrantPrincipalTypeUser GrantPrincipalType = "user" - GrantPrincipalTypeTeam GrantPrincipalType = "team" -) - -// AllValues returns all GrantPrincipalType values. -func (GrantPrincipalType) AllValues() []GrantPrincipalType { - return []GrantPrincipalType{ - GrantPrincipalTypeUser, - GrantPrincipalTypeTeam, - } -} - -// MarshalText implements encoding.TextMarshaler. -func (s GrantPrincipalType) MarshalText() ([]byte, error) { - switch s { - case GrantPrincipalTypeUser: - return []byte(s), nil - case GrantPrincipalTypeTeam: - return []byte(s), nil - default: - return nil, errors.Errorf("invalid value: %q", s) - } -} - -// UnmarshalText implements encoding.TextUnmarshaler. -func (s *GrantPrincipalType) UnmarshalText(data []byte) error { - switch GrantPrincipalType(data) { - case GrantPrincipalTypeUser: - *s = GrantPrincipalTypeUser - return nil - case GrantPrincipalTypeTeam: - *s = GrantPrincipalTypeTeam - return nil - default: - return errors.Errorf("invalid value: %q", data) - } -} - // Catalog relation on the project. type GrantRelation string @@ -22831,6 +22693,186 @@ func (s *GrantSortingField) UnmarshalText(data []byte) error { } } +// Merged schema. +// Ref: # +type GrantTeam struct { + // The referenced team's id (`team_`). Always present. + TeamID string `json:"team_id"` + // The team's name. Absent when the team can no longer be loaded. + Name OptString `json:"name"` + // Team lifecycle. Present only when the request asked for + // `expand: ["principal"]` and the team could be loaded. + Status OptTeamStatus `json:"status"` + // When the team was created. Present only when expand loaded the team. + CreatedAt OptDateTime `json:"created_at"` + // When the team was last updated. Present only when expand loaded the team. + UpdatedAt OptDateTime `json:"updated_at"` +} + +// GetTeamID returns the value of TeamID. +func (s *GrantTeam) GetTeamID() string { + return s.TeamID +} + +// GetName returns the value of Name. +func (s *GrantTeam) GetName() OptString { + return s.Name +} + +// GetStatus returns the value of Status. +func (s *GrantTeam) GetStatus() OptTeamStatus { + return s.Status +} + +// GetCreatedAt returns the value of CreatedAt. +func (s *GrantTeam) GetCreatedAt() OptDateTime { + return s.CreatedAt +} + +// GetUpdatedAt returns the value of UpdatedAt. +func (s *GrantTeam) GetUpdatedAt() OptDateTime { + return s.UpdatedAt +} + +// SetTeamID sets the value of TeamID. +func (s *GrantTeam) SetTeamID(val string) { + s.TeamID = val +} + +// SetName sets the value of Name. +func (s *GrantTeam) SetName(val OptString) { + s.Name = val +} + +// SetStatus sets the value of Status. +func (s *GrantTeam) SetStatus(val OptTeamStatus) { + s.Status = val +} + +// SetCreatedAt sets the value of CreatedAt. +func (s *GrantTeam) SetCreatedAt(val OptDateTime) { + s.CreatedAt = val +} + +// SetUpdatedAt sets the value of UpdatedAt. +func (s *GrantTeam) SetUpdatedAt(val OptDateTime) { + s.UpdatedAt = val +} + +// Merged schema. +// Ref: # +type GrantUser struct { + // The referenced user's id. Always present. + UserID UserID `json:"user_id"` + // The current value of the schema's designated identifier + // (`x-identifier`). Absent when the schema designates no identifier or + // the user carries no value for it. + Identifier OptString `json:"identifier"` + // The schema property `identifier` came from, so clients can reach the + // property's schema for semantics (a mailto link, a field label) + // instead of guessing from the value. Present exactly when + // `identifier` is. + IdentifierProperty OptString `json:"identifier_property"` + // The `x-display` rendering — the designated properties' values joined + // in list order. Purely presentational, with no source attribution. + // Absent when the schema designates no display properties or the user + // carries no values for them. + Display OptString `json:"display"` + // The schema that defines `attributes`. Present only when the request + // asked for `expand: ["principal"]` and the user could be loaded. + Schema OptString `json:"schema"` + // The user's schema document. Present only when the request asked + // for `expand: ["principal"]` and the user could be loaded. + Attributes OptGrantUserAttributes `json:"attributes"` + // Server-owned user envelope. Present only when the request asked + // for `expand: ["principal"]` and the user could be loaded. Grant + // expand does not populate `lifecycle_owner_team`. + Metadata OptUserMetadata `json:"metadata"` +} + +// GetUserID returns the value of UserID. +func (s *GrantUser) GetUserID() UserID { + return s.UserID +} + +// GetIdentifier returns the value of Identifier. +func (s *GrantUser) GetIdentifier() OptString { + return s.Identifier +} + +// GetIdentifierProperty returns the value of IdentifierProperty. +func (s *GrantUser) GetIdentifierProperty() OptString { + return s.IdentifierProperty +} + +// GetDisplay returns the value of Display. +func (s *GrantUser) GetDisplay() OptString { + return s.Display +} + +// GetSchema returns the value of Schema. +func (s *GrantUser) GetSchema() OptString { + return s.Schema +} + +// GetAttributes returns the value of Attributes. +func (s *GrantUser) GetAttributes() OptGrantUserAttributes { + return s.Attributes +} + +// GetMetadata returns the value of Metadata. +func (s *GrantUser) GetMetadata() OptUserMetadata { + return s.Metadata +} + +// SetUserID sets the value of UserID. +func (s *GrantUser) SetUserID(val UserID) { + s.UserID = val +} + +// SetIdentifier sets the value of Identifier. +func (s *GrantUser) SetIdentifier(val OptString) { + s.Identifier = val +} + +// SetIdentifierProperty sets the value of IdentifierProperty. +func (s *GrantUser) SetIdentifierProperty(val OptString) { + s.IdentifierProperty = val +} + +// SetDisplay sets the value of Display. +func (s *GrantUser) SetDisplay(val OptString) { + s.Display = val +} + +// SetSchema sets the value of Schema. +func (s *GrantUser) SetSchema(val OptString) { + s.Schema = val +} + +// SetAttributes sets the value of Attributes. +func (s *GrantUser) SetAttributes(val OptGrantUserAttributes) { + s.Attributes = val +} + +// SetMetadata sets the value of Metadata. +func (s *GrantUser) SetMetadata(val OptUserMetadata) { + s.Metadata = val +} + +// The user's schema document. Present only when the request asked +// for `expand: ["principal"]` and the user could be loaded. +type GrantUserAttributes map[string]jx.Raw + +func (s *GrantUserAttributes) init() GrantUserAttributes { + m := *s + if m == nil { + m = map[string]jx.Raw{} + *s = m + } + return m +} + // The handoff token and metadata for session exchange. // This is a short-lived credential (TTL ≤ 60 seconds) that the client must exchange // at POST /sessions/exchange to receive the final session and session_token. @@ -29448,6 +29490,144 @@ func (o OptGrantPrincipalNotFoundDetails) Or(d GrantPrincipalNotFoundDetails) Gr return d } +// NewOptGrantTeam returns new OptGrantTeam with value set to v. +func NewOptGrantTeam(v GrantTeam) OptGrantTeam { + return OptGrantTeam{ + Value: v, + Set: true, + } +} + +// OptGrantTeam is optional GrantTeam. +type OptGrantTeam struct { + Value GrantTeam + Set bool +} + +// IsSet returns true if OptGrantTeam was set. +func (o OptGrantTeam) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptGrantTeam) Reset() { + var v GrantTeam + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptGrantTeam) SetTo(v GrantTeam) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptGrantTeam) Get() (v GrantTeam, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptGrantTeam) Or(d GrantTeam) GrantTeam { + if v, ok := o.Get(); ok { + return v + } + return d +} + +// NewOptGrantUser returns new OptGrantUser with value set to v. +func NewOptGrantUser(v GrantUser) OptGrantUser { + return OptGrantUser{ + Value: v, + Set: true, + } +} + +// OptGrantUser is optional GrantUser. +type OptGrantUser struct { + Value GrantUser + Set bool +} + +// IsSet returns true if OptGrantUser was set. +func (o OptGrantUser) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptGrantUser) Reset() { + var v GrantUser + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptGrantUser) SetTo(v GrantUser) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptGrantUser) Get() (v GrantUser, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptGrantUser) Or(d GrantUser) GrantUser { + if v, ok := o.Get(); ok { + return v + } + return d +} + +// NewOptGrantUserAttributes returns new OptGrantUserAttributes with value set to v. +func NewOptGrantUserAttributes(v GrantUserAttributes) OptGrantUserAttributes { + return OptGrantUserAttributes{ + Value: v, + Set: true, + } +} + +// OptGrantUserAttributes is optional GrantUserAttributes. +type OptGrantUserAttributes struct { + Value GrantUserAttributes + Set bool +} + +// IsSet returns true if OptGrantUserAttributes was set. +func (o OptGrantUserAttributes) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptGrantUserAttributes) Reset() { + var v GrantUserAttributes + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptGrantUserAttributes) SetTo(v GrantUserAttributes) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptGrantUserAttributes) Get() (v GrantUserAttributes, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptGrantUserAttributes) Or(d GrantUserAttributes) GrantUserAttributes { + if v, ok := o.Get(); ok { + return v + } + return d +} + // NewOptInt returns new OptInt with value set to v. func NewOptInt(v int) OptInt { return OptInt{ @@ -30996,69 +31176,6 @@ func (o OptNilFlowdefUpdatedEventActorType) Or(d FlowdefUpdatedEventActorType) F return d } -// NewOptNilGrantExpandedPrincipal returns new OptNilGrantExpandedPrincipal with value set to v. -func NewOptNilGrantExpandedPrincipal(v GrantExpandedPrincipal) OptNilGrantExpandedPrincipal { - return OptNilGrantExpandedPrincipal{ - Value: v, - Set: true, - } -} - -// OptNilGrantExpandedPrincipal is optional nullable GrantExpandedPrincipal. -type OptNilGrantExpandedPrincipal struct { - Value GrantExpandedPrincipal - Set bool - Null bool -} - -// IsSet returns true if OptNilGrantExpandedPrincipal was set. -func (o OptNilGrantExpandedPrincipal) IsSet() bool { return o.Set } - -// Reset unsets value. -func (o *OptNilGrantExpandedPrincipal) Reset() { - var v GrantExpandedPrincipal - o.Value = v - o.Set = false - o.Null = false -} - -// SetTo sets value to v. -func (o *OptNilGrantExpandedPrincipal) SetTo(v GrantExpandedPrincipal) { - o.Set = true - o.Null = false - o.Value = v -} - -// IsNull returns true if value is Null. -func (o OptNilGrantExpandedPrincipal) IsNull() bool { return o.Null } - -// SetToNull sets value to null. -func (o *OptNilGrantExpandedPrincipal) SetToNull() { - o.Set = true - o.Null = true - var v GrantExpandedPrincipal - o.Value = v -} - -// Get returns value and boolean that denotes whether value was set. -func (o OptNilGrantExpandedPrincipal) Get() (v GrantExpandedPrincipal, ok bool) { - if o.Null { - return v, false - } - if !o.Set { - return v, false - } - return o.Value, true -} - -// Or returns value if set, or given parameter if does not. -func (o OptNilGrantExpandedPrincipal) Or(d GrantExpandedPrincipal) GrantExpandedPrincipal { - if v, ok := o.Get(); ok { - return v - } - return d -} - // NewOptNilPageToken returns new OptNilPageToken with value set to v. func NewOptNilPageToken(v PageToken) OptNilPageToken { return OptNilPageToken{ @@ -34411,6 +34528,52 @@ func (o OptTeamID) Or(d TeamID) TeamID { return d } +// NewOptTeamLocator returns new OptTeamLocator with value set to v. +func NewOptTeamLocator(v TeamLocator) OptTeamLocator { + return OptTeamLocator{ + Value: v, + Set: true, + } +} + +// OptTeamLocator is optional TeamLocator. +type OptTeamLocator struct { + Value TeamLocator + Set bool +} + +// IsSet returns true if OptTeamLocator was set. +func (o OptTeamLocator) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptTeamLocator) Reset() { + var v TeamLocator + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptTeamLocator) SetTo(v TeamLocator) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptTeamLocator) Get() (v TeamLocator, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptTeamLocator) Or(d TeamLocator) TeamLocator { + if v, ok := o.Get(); ok { + return v + } + return d +} + // NewOptTeamPermissionDeniedDetails returns new OptTeamPermissionDeniedDetails with value set to v. func NewOptTeamPermissionDeniedDetails(v TeamPermissionDeniedDetails) OptTeamPermissionDeniedDetails { return OptTeamPermissionDeniedDetails{ @@ -34457,38 +34620,38 @@ func (o OptTeamPermissionDeniedDetails) Or(d TeamPermissionDeniedDetails) TeamPe return d } -// NewOptTeamRef returns new OptTeamRef with value set to v. -func NewOptTeamRef(v TeamRef) OptTeamRef { - return OptTeamRef{ +// NewOptTeamStatus returns new OptTeamStatus with value set to v. +func NewOptTeamStatus(v TeamStatus) OptTeamStatus { + return OptTeamStatus{ Value: v, Set: true, } } -// OptTeamRef is optional TeamRef. -type OptTeamRef struct { - Value TeamRef +// OptTeamStatus is optional TeamStatus. +type OptTeamStatus struct { + Value TeamStatus Set bool } -// IsSet returns true if OptTeamRef was set. -func (o OptTeamRef) IsSet() bool { return o.Set } +// IsSet returns true if OptTeamStatus was set. +func (o OptTeamStatus) IsSet() bool { return o.Set } // Reset unsets value. -func (o *OptTeamRef) Reset() { - var v TeamRef +func (o *OptTeamStatus) Reset() { + var v TeamStatus o.Value = v o.Set = false } // SetTo sets value to v. -func (o *OptTeamRef) SetTo(v TeamRef) { +func (o *OptTeamStatus) SetTo(v TeamStatus) { o.Set = true o.Value = v } // Get returns value and boolean that denotes whether value was set. -func (o OptTeamRef) Get() (v TeamRef, ok bool) { +func (o OptTeamStatus) Get() (v TeamStatus, ok bool) { if !o.Set { return v, false } @@ -34496,7 +34659,7 @@ func (o OptTeamRef) Get() (v TeamRef, ok bool) { } // Or returns value if set, or given parameter if does not. -func (o OptTeamRef) Or(d TeamRef) TeamRef { +func (o OptTeamStatus) Or(d TeamStatus) TeamStatus { if v, ok := o.Get(); ok { return v } @@ -34963,6 +35126,52 @@ func (o OptUserDeletedEventDelegationType) Or(d UserDeletedEventDelegationType) return d } +// NewOptUserID returns new OptUserID with value set to v. +func NewOptUserID(v UserID) OptUserID { + return OptUserID{ + Value: v, + Set: true, + } +} + +// OptUserID is optional UserID. +type OptUserID struct { + Value UserID + Set bool +} + +// IsSet returns true if OptUserID was set. +func (o OptUserID) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptUserID) Reset() { + var v UserID + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptUserID) SetTo(v UserID) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptUserID) Get() (v UserID, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptUserID) Or(d UserID) UserID { + if v, ok := o.Get(); ok { + return v + } + return d +} + // NewOptUserInvalidDetails returns new OptUserInvalidDetails with value set to v. func NewOptUserInvalidDetails(v UserInvalidDetails) OptUserInvalidDetails { return OptUserInvalidDetails{ @@ -35009,6 +35218,98 @@ func (o OptUserInvalidDetails) Or(d UserInvalidDetails) UserInvalidDetails { return d } +// NewOptUserLocator returns new OptUserLocator with value set to v. +func NewOptUserLocator(v UserLocator) OptUserLocator { + return OptUserLocator{ + Value: v, + Set: true, + } +} + +// OptUserLocator is optional UserLocator. +type OptUserLocator struct { + Value UserLocator + Set bool +} + +// IsSet returns true if OptUserLocator was set. +func (o OptUserLocator) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptUserLocator) Reset() { + var v UserLocator + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptUserLocator) SetTo(v UserLocator) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptUserLocator) Get() (v UserLocator, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptUserLocator) Or(d UserLocator) UserLocator { + if v, ok := o.Get(); ok { + return v + } + return d +} + +// NewOptUserMetadata returns new OptUserMetadata with value set to v. +func NewOptUserMetadata(v UserMetadata) OptUserMetadata { + return OptUserMetadata{ + Value: v, + Set: true, + } +} + +// OptUserMetadata is optional UserMetadata. +type OptUserMetadata struct { + Value UserMetadata + Set bool +} + +// IsSet returns true if OptUserMetadata was set. +func (o OptUserMetadata) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptUserMetadata) Reset() { + var v UserMetadata + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptUserMetadata) SetTo(v UserMetadata) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptUserMetadata) Get() (v UserMetadata, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptUserMetadata) Or(d UserMetadata) UserMetadata { + if v, ok := o.Get(); ok { + return v + } + return d +} + // NewOptUserNotFoundDetails returns new OptUserNotFoundDetails with value set to v. func NewOptUserNotFoundDetails(v UserNotFoundDetails) OptUserNotFoundDetails { return OptUserNotFoundDetails{ @@ -37955,8 +38256,9 @@ type QueryGrantsRequest struct { // `sorting` as the request that issued the token. Omitting `sorting` reuses // the default sort and only succeeds when that default matches the token. PageToken OptNilPageToken `json:"page_token"` - // Related objects to embed on each grant (ADR 059). Omit it and no - // embedded object is returned. An unrecognised value is rejected. + // Extra principal fields to add onto each grant's `user` or `team` + // (ADR 059 grant exception). Omit it and those objects stay refs. An + // unrecognised value is rejected. Expand []GrantExpand `json:"expand"` Sorting OptQueryGrantsRequestSorting `json:"sorting"` // Filter criteria for querying grants. Combined with AND. @@ -46239,6 +46541,37 @@ func (s *TeamFilterField) UnmarshalText(data []byte) error { type TeamID string +// Name a team with exactly one of `team_id` or `name`. Sending both, +// neither, or any other field is `grant.invalid`. +// Ref: # +type TeamLocator struct { + // Platform-homed team id (`team_`). The team must be active. + TeamID OptTeamID `json:"team_id"` + // Team name, unique per project case-insensitively. Looked up in + // the platform project. The team must be active. + Name OptString `json:"name"` +} + +// GetTeamID returns the value of TeamID. +func (s *TeamLocator) GetTeamID() OptTeamID { + return s.TeamID +} + +// GetName returns the value of Name. +func (s *TeamLocator) GetName() OptString { + return s.Name +} + +// SetTeamID sets the value of TeamID. +func (s *TeamLocator) SetTeamID(val OptTeamID) { + s.TeamID = val +} + +// SetName sets the value of Name. +func (s *TeamLocator) SetName(val OptString) { + s.Name = val +} + // Shared allowlisted fields for `team.created` (snapshot) and // `team.updated` (delta: only changed fields present). // Ref: # @@ -46309,39 +46642,6 @@ func (s *TeamPermissionDeniedDetails) init() TeamPermissionDeniedDetails { return m } -// A resolved reference to a team. Carries the team's id and name so a grant -// list is readable without embedding the full Team body. Id and display ride -// `project.read` (ADR 059 rule 8): a reference field carrying only the -// target's id and display strings needs no gate of its own. Missing or -// deleted teams degrade to `team_id` only. -// Ref: # -type TeamRef struct { - // The referenced team's id (`team_`). Always present. - TeamID string `json:"team_id"` - // The team's name. Absent when the team can no longer be loaded. - Name OptString `json:"name"` -} - -// GetTeamID returns the value of TeamID. -func (s *TeamRef) GetTeamID() string { - return s.TeamID -} - -// GetName returns the value of Name. -func (s *TeamRef) GetName() OptString { - return s.Name -} - -// SetTeamID sets the value of TeamID. -func (s *TeamRef) SetTeamID(val string) { - s.TeamID = val -} - -// SetName sets the value of Name. -func (s *TeamRef) SetName(val OptString) { - s.Name = val -} - // Details of a team. // Ref: # type TeamResponse struct { @@ -49298,6 +49598,38 @@ func (s *UserInvalidDetails) init() UserInvalidDetails { return m } +// Name a user with exactly one of `user_id` or `identifier`. Sending both, +// neither, or any other field is `grant.invalid`. +// Ref: # +type UserLocator struct { + // Platform-homed user id (`user_`). The user must be active. + UserID OptUserID `json:"user_id"` + // The user schema's designated identifier (`x-identifier`), looked + // up in the platform project. Exactly one active match is required; + // zero or several resolve as not found. + Identifier OptString `json:"identifier"` +} + +// GetUserID returns the value of UserID. +func (s *UserLocator) GetUserID() OptUserID { + return s.UserID +} + +// GetIdentifier returns the value of Identifier. +func (s *UserLocator) GetIdentifier() OptString { + return s.Identifier +} + +// SetUserID sets the value of UserID. +func (s *UserLocator) SetUserID(val OptUserID) { + s.UserID = val +} + +// SetIdentifier sets the value of Identifier. +func (s *UserLocator) SetIdentifier(val OptString) { + s.Identifier = val +} + // Ref: # type UserMetadata struct { // The time when the user was created. diff --git a/api/generated/oas_server_gen.go b/api/generated/oas_server_gen.go index 5002a8fe6..f74d2515b 100644 --- a/api/generated/oas_server_gen.go +++ b/api/generated/oas_server_gen.go @@ -81,10 +81,12 @@ type Handler interface { // // Bind a user or team to `project.viewer`, `project.editor`, or // `project.admin` on the project identified by the `project-id` header. - // IDs are `asgn_`. Owning-team (`project.team`) grants are not - // created here — claim owns that path. An unrevoked grant with the same - // principal and relation occupies the unique key even after `expires_at`; - // DELETE it before re-creating. + // Name the principal with `user` (`user_id` or `identifier`) or `team` + // (`team_id` or `name`). IDs are `asgn_`. Owning-team + // (`project.team`) grants are not created here — claim owns that path. An + // unrevoked grant with the same principal and relation occupies the unique + // key even after `expires_at`; DELETE it before re-creating. + // Create does not accept `expand`; the 201 `user` / `team` are refs only. // // POST /grants CreateGrant(ctx context.Context, req *CreateGrantRequest, params CreateGrantParams) (CreateGrantRes, error) @@ -316,6 +318,8 @@ type Handler interface { // `resource_scope_index`; project scope is required on the query (same as // events). Misses, revoked rows, project-secret setup (`sk_proj`), // owning-team (`relation=team`) rows, and cross-project ids return 404. + // `expand=principal` adds envelope fields on `user` or `team` and requires + // `user.read` and `team.read` in addition to `project.read`. // // GET /grants/{id} GetGrant(ctx context.Context, params GetGrantParams) (GetGrantRes, error) @@ -506,10 +510,10 @@ type Handler interface { // DELETE before re-granting. Project-secret setup (`sk_proj`) and // owning-team (`relation=team`) rows are not returned. Grants are not in // `resource_scope_index`; project scope is required on the query (same as - // get). Requires `project.read`. `expand: ["principal"]` additionally - // requires `user.read` and `team.read` (documented on the expand enum; - // those scopes cannot be ANDed onto this security block because they are - // body-conditional). + // get). Requires `project.read`. `expand: ["principal"]` adds envelope + // fields on `user` / `team` and additionally requires `user.read` and + // `team.read` (documented on the expand enum; those scopes cannot be ANDed + // onto this security block because they are body-conditional). // // POST /grants/query QueryGrants(ctx context.Context, req *QueryGrantsRequest, params QueryGrantsParams) (QueryGrantsRes, error) diff --git a/api/generated/oas_unimplemented_gen.go b/api/generated/oas_unimplemented_gen.go index 717496bbf..730cdeab5 100644 --- a/api/generated/oas_unimplemented_gen.go +++ b/api/generated/oas_unimplemented_gen.go @@ -104,10 +104,12 @@ func (UnimplementedHandler) CreateFlowDefinition(ctx context.Context, req *Creat // // Bind a user or team to `project.viewer`, `project.editor`, or // `project.admin` on the project identified by the `project-id` header. -// IDs are `asgn_`. Owning-team (`project.team`) grants are not -// created here — claim owns that path. An unrevoked grant with the same -// principal and relation occupies the unique key even after `expires_at`; -// DELETE it before re-creating. +// Name the principal with `user` (`user_id` or `identifier`) or `team` +// (`team_id` or `name`). IDs are `asgn_`. Owning-team +// (`project.team`) grants are not created here — claim owns that path. An +// unrevoked grant with the same principal and relation occupies the unique +// key even after `expires_at`; DELETE it before re-creating. +// Create does not accept `expand`; the 201 `user` / `team` are refs only. // // POST /grants func (UnimplementedHandler) CreateGrant(ctx context.Context, req *CreateGrantRequest, params CreateGrantParams) (r CreateGrantRes, _ error) { @@ -405,6 +407,8 @@ func (UnimplementedHandler) GetFlowStep(ctx context.Context, params GetFlowStepP // `resource_scope_index`; project scope is required on the query (same as // events). Misses, revoked rows, project-secret setup (`sk_proj`), // owning-team (`relation=team`) rows, and cross-project ids return 404. +// `expand=principal` adds envelope fields on `user` or `team` and requires +// `user.read` and `team.read` in addition to `project.read`. // // GET /grants/{id} func (UnimplementedHandler) GetGrant(ctx context.Context, params GetGrantParams) (r GetGrantRes, _ error) { @@ -664,10 +668,10 @@ func (UnimplementedHandler) PatchProject(ctx context.Context, req *PatchProjectR // DELETE before re-granting. Project-secret setup (`sk_proj`) and // owning-team (`relation=team`) rows are not returned. Grants are not in // `resource_scope_index`; project scope is required on the query (same as -// get). Requires `project.read`. `expand: ["principal"]` additionally -// requires `user.read` and `team.read` (documented on the expand enum; -// those scopes cannot be ANDed onto this security block because they are -// body-conditional). +// get). Requires `project.read`. `expand: ["principal"]` adds envelope +// fields on `user` / `team` and additionally requires `user.read` and +// `team.read` (documented on the expand enum; those scopes cannot be ANDed +// onto this security block because they are body-conditional). // // POST /grants/query func (UnimplementedHandler) QueryGrants(ctx context.Context, req *QueryGrantsRequest, params QueryGrantsParams) (r QueryGrantsRes, _ error) { diff --git a/api/generated/oas_validators_gen.go b/api/generated/oas_validators_gen.go index 9579697d3..68bb6d1ad 100644 --- a/api/generated/oas_validators_gen.go +++ b/api/generated/oas_validators_gen.go @@ -2833,47 +2833,49 @@ func (s *CreateGrantRequest) Validate() error { var failures []validate.FieldError if err := func() error { - if err := s.PrincipalType.Validate(); err != nil { + if err := s.Relation.Validate(); err != nil { return err } return nil }(); err != nil { failures = append(failures, validate.FieldError{ - Name: "principal_type", + Name: "relation", Error: err, }) } if err := func() error { - if err := (validate.String{ - MinLength: 1, - MinLengthSet: true, - MaxLength: 0, - MaxLengthSet: false, - Email: false, - Hostname: false, - Regex: nil, - MinNumeric: 0, - MinNumericSet: false, - MaxNumeric: 0, - MaxNumericSet: false, - }).Validate(string(s.PrincipalID)); err != nil { - return errors.Wrap(err, "string") + if value, ok := s.User.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } } return nil }(); err != nil { failures = append(failures, validate.FieldError{ - Name: "principal_id", + Name: "user", Error: err, }) } if err := func() error { - if err := s.Relation.Validate(); err != nil { - return err + if value, ok := s.Team.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } } return nil }(); err != nil { failures = append(failures, validate.FieldError{ - Name: "relation", + Name: "team", Error: err, }) } @@ -2883,17 +2885,6 @@ func (s *CreateGrantRequest) Validate() error { return nil } -func (s CreateGrantRequestPrincipalType) Validate() error { - switch s { - case "user": - return nil - case "team": - return nil - default: - return errors.Errorf("invalid value: %v", s) - } -} - func (s CreateGrantRequestRelation) Validate() error { switch s { case "viewer": @@ -4889,17 +4880,6 @@ func (s *Grant) Validate() error { } var failures []validate.FieldError - if err := func() error { - if err := s.PrincipalType.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - failures = append(failures, validate.FieldError{ - Name: "principal_type", - Error: err, - }) - } if err := func() error { if err := s.ObjectType.Validate(); err != nil { return err @@ -4941,7 +4921,7 @@ func (s *Grant) Validate() error { }) } if err := func() error { - if value, ok := s.Principal.Get(); ok { + if value, ok := s.Team.Get(); ok { if err := func() error { if err := value.Validate(); err != nil { return err @@ -4954,7 +4934,7 @@ func (s *Grant) Validate() error { return nil }(); err != nil { failures = append(failures, validate.FieldError{ - Name: "principal", + Name: "team", Error: err, }) } @@ -4973,30 +4953,13 @@ func (s GrantExpand) Validate() error { } } -func (s GrantExpandedPrincipal) Validate() error { - switch s.Type { - case UserGrantExpandedPrincipal: - if err := s.User.Validate(); err != nil { - return err - } - return nil - case TeamResponseGrantExpandedPrincipal: - if err := s.TeamResponse.Validate(); err != nil { - return err - } - return nil - default: - return errors.Errorf("invalid type %q", s.Type) - } -} - func (s GrantFilterField) Validate() error { switch s { case "created_at": return nil - case "principal_type": + case "user_id": return nil - case "principal_id": + case "team_id": return nil case "relation": return nil @@ -5016,17 +4979,6 @@ func (s GrantObjectType) Validate() error { } } -func (s GrantPrincipalType) Validate() error { - switch s { - case "user": - return nil - case "team": - return nil - default: - return errors.Errorf("invalid value: %v", s) - } -} - func (s GrantRelation) Validate() error { switch s { case "viewer": @@ -5053,6 +5005,77 @@ func (s GrantSortingField) Validate() error { } } +func (s *GrantTeam) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if value, ok := s.Status.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "status", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *GrantUser) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := s.UserID.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "user_id", + Error: err, + }) + } + if err := func() error { + if value, ok := s.Metadata.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "metadata", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + func (s *IdentifierFactorPayload) Validate() error { if s == nil { return validate.ErrNilPointer @@ -8733,6 +8756,66 @@ func (s TeamID) Validate() error { return nil } +func (s *TeamLocator) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if value, ok := s.TeamID.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "team_id", + Error: err, + }) + } + if err := func() error { + if value, ok := s.Name.Get(); ok { + if err := func() error { + if err := (validate.String{ + MinLength: 1, + MinLengthSet: true, + MaxLength: 0, + MaxLengthSet: false, + Email: false, + Hostname: false, + Regex: nil, + MinNumeric: 0, + MinNumericSet: false, + MaxNumeric: 0, + MaxNumericSet: false, + }).Validate(string(value)); err != nil { + return errors.Wrap(err, "string") + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "name", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + func (s *TeamResponse) Validate() error { if s == nil { return validate.ErrNilPointer @@ -9394,6 +9477,66 @@ func (s UserID) Validate() error { return nil } +func (s *UserLocator) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if value, ok := s.UserID.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "user_id", + Error: err, + }) + } + if err := func() error { + if value, ok := s.Identifier.Get(); ok { + if err := func() error { + if err := (validate.String{ + MinLength: 1, + MinLengthSet: true, + MaxLength: 0, + MaxLengthSet: false, + Email: false, + Hostname: false, + Regex: nil, + MinNumeric: 0, + MinNumericSet: false, + MaxNumeric: 0, + MaxNumericSet: false, + }).Validate(string(value)); err != nil { + return errors.Wrap(err, "string") + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "identifier", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + func (s *UserMetadata) Validate() error { if s == nil { return validate.ErrNilPointer diff --git a/api/openapi/endpoints/grants/by_id/getGrant-error-response.yaml b/api/openapi/endpoints/grants/by_id/getGrant-error-response.yaml index ef2f8b7a1..bd2886d0f 100644 --- a/api/openapi/endpoints/grants/by_id/getGrant-error-response.yaml +++ b/api/openapi/endpoints/grants/by_id/getGrant-error-response.yaml @@ -9,6 +9,8 @@ content: - $ref: '../../../components/schemas/errors/grant-permission_denied.yaml' - $ref: '../../../components/schemas/errors/internal.yaml' - $ref: '../../../components/schemas/errors/req-invalid.yaml' + - $ref: '../../../components/schemas/errors/team-permission_denied.yaml' + - $ref: '../../../components/schemas/errors/user-permission_denied.yaml' discriminator: propertyName: code mapping: @@ -17,6 +19,8 @@ content: grant.permission_denied: '../../../components/schemas/errors/grant-permission_denied.yaml' internal: '../../../components/schemas/errors/internal.yaml' req.invalid: '../../../components/schemas/errors/req-invalid.yaml' + team.permission_denied: '../../../components/schemas/errors/team-permission_denied.yaml' + user.permission_denied: '../../../components/schemas/errors/user-permission_denied.yaml' examples: auth.unauthorized: summary: auth.unauthorized @@ -43,3 +47,13 @@ content: value: code: req.invalid message: 'The request is invalid and fails base validation (missing required fields, wrong types, failed regex, etc.). Check the details for more information.' + team.permission_denied: + summary: team.permission_denied + value: + code: team.permission_denied + message: 'the team management API requires the project secret' + user.permission_denied: + summary: user.permission_denied + value: + code: user.permission_denied + message: 'the user management API requires the project secret' diff --git a/api/openapi/endpoints/grants/by_id/methods.yaml b/api/openapi/endpoints/grants/by_id/methods.yaml index f34375d06..e6b7fa66a 100644 --- a/api/openapi/endpoints/grants/by_id/methods.yaml +++ b/api/openapi/endpoints/grants/by_id/methods.yaml @@ -10,6 +10,8 @@ get: `resource_scope_index`; project scope is required on the query (same as events). Misses, revoked rows, project-secret setup (`sk_proj`), owning-team (`relation=team`) rows, and cross-project ids return 404. + `expand=principal` adds envelope fields on `user` or `team` and requires + `user.read` and `team.read` in addition to `project.read`. tags: - Grants security: @@ -23,6 +25,17 @@ get: type: string description: Managed assignment id (`asgn_`). - $ref: ../../../components/parameters/project-id.yaml + - name: expand + in: query + required: false + description: | + Extra principal fields to add onto `user` or `team`. Same enum and + `user.read` + `team.read` gate as `POST /grants/query`. + schema: + type: array + uniqueItems: true + items: + $ref: ../query/grant-expand.yaml responses: '200': description: The grant. diff --git a/api/openapi/endpoints/grants/create-grant-request.yaml b/api/openapi/endpoints/grants/create-grant-request.yaml index 86b9d48e2..f718b1d8e 100644 --- a/api/openapi/endpoints/grants/create-grant-request.yaml +++ b/api/openapi/endpoints/grants/create-grant-request.yaml @@ -1,21 +1,12 @@ +title: CreateGrantRequest +description: | + Bind a user or a team. Exactly one of `user` or `team`. Shared fields are + `relation` and optional `expires_at`. Sending both locators or neither is + `grant.invalid`. type: object required: - - principal_type - - principal_id - relation properties: - principal_type: - type: string - enum: - - user - - team - description: Kind of principal to bind. `sk_proj` and owning-team grants are not accepted. - principal_id: - type: string - minLength: 1 - description: | - Principal id (`user_` or `team_`). The principal must - exist in the platform project when a platform project is configured. relation: type: string enum: @@ -30,3 +21,15 @@ properties: Optional expiry. Must be in the future when set. Expiry stops authorization but does not free the unique binding; DELETE the grant before posting the same principal and relation again. + user: + description: | + User to bind. Exactly one of `user_id` or `identifier`. The user must + exist in the platform project when a platform project is configured. + Mutually exclusive with `team`. + $ref: user-locator.yaml + team: + description: | + Team to bind. Exactly one of `team_id` or `name`. The team must exist + in the platform project when a platform project is configured. + Mutually exclusive with `user`. + $ref: team-locator.yaml diff --git a/api/openapi/endpoints/grants/grant-expanded-principal.yaml b/api/openapi/endpoints/grants/grant-expanded-principal.yaml deleted file mode 100644 index 3a2ba14f6..000000000 --- a/api/openapi/endpoints/grants/grant-expanded-principal.yaml +++ /dev/null @@ -1,21 +0,0 @@ -description: | - The principal bound by a grant: the User body when `principal_type` is - `user`, or the Team body when `principal_type` is `team`. Same - representation as `GET /users/{id}` and `GET /teams/{id}` respectively. - Clients discriminate with the grant's existing `principal_type`. -oneOf: - - type: object - required: - - schema - - attributes - - metadata - allOf: - - $ref: ../users/user.yaml - - type: object - required: - - name - - status - - created_at - - updated_at - allOf: - - $ref: ../teams/team-response.yaml diff --git a/api/openapi/endpoints/grants/grant-team.yaml b/api/openapi/endpoints/grants/grant-team.yaml new file mode 100644 index 000000000..7d923a109 --- /dev/null +++ b/api/openapi/endpoints/grants/grant-team.yaml @@ -0,0 +1,25 @@ +title: GrantTeam +description: | + Team bound by this grant. Always a team-ref (`team_id`, never `id`). + `expand: ["principal"]` adds `status`, `created_at`, and `updated_at` on + this same object. Missing teams degrade to `team_id` only, with or without + expand (ADR 059 grant exception). +allOf: + - $ref: ../../components/schemas/team-ref.yaml + - type: object + properties: + status: + $ref: ../../components/schemas/team-status.yaml + description: | + Team lifecycle. Present only when the request asked for + `expand: ["principal"]` and the team could be loaded. + created_at: + type: string + format: date-time + description: | + When the team was created. Present only when expand loaded the team. + updated_at: + type: string + format: date-time + description: | + When the team was last updated. Present only when expand loaded the team. diff --git a/api/openapi/endpoints/grants/grant-user.yaml b/api/openapi/endpoints/grants/grant-user.yaml new file mode 100644 index 000000000..55fea4725 --- /dev/null +++ b/api/openapi/endpoints/grants/grant-user.yaml @@ -0,0 +1,27 @@ +title: GrantUser +description: | + User bound by this grant. Always a user-ref (`user_id`, never `id`). + `expand: ["principal"]` adds the User envelope fields `schema`, + `attributes`, and `metadata` on this same object. Missing users degrade to + `user_id` only, with or without expand (ADR 059 grant exception). +allOf: + - $ref: ../../components/schemas/user-ref.yaml + - type: object + properties: + schema: + type: string + description: | + The schema that defines `attributes`. Present only when the request + asked for `expand: ["principal"]` and the user could be loaded. + attributes: + type: object + additionalProperties: true + description: | + The user's schema document. Present only when the request asked + for `expand: ["principal"]` and the user could be loaded. + metadata: + description: | + Server-owned user envelope. Present only when the request asked + for `expand: ["principal"]` and the user could be loaded. Grant + expand does not populate `lifecycle_owner_team`. + $ref: ../users/user-metadata.yaml diff --git a/api/openapi/endpoints/grants/grant.yaml b/api/openapi/endpoints/grants/grant.yaml index c932bab80..3d9779b61 100644 --- a/api/openapi/endpoints/grants/grant.yaml +++ b/api/openapi/endpoints/grants/grant.yaml @@ -1,10 +1,13 @@ +title: Grant +description: | + A collaboration grant binding a user or a team to a project relation. + Discriminate on which of `user` or `team` is present. Those objects are + always refs (`user_id` / `team_id`); expand adds extra fields on the same + object rather than a sibling. type: object -description: A collaboration grant binding a principal to a project relation. required: - id - project_id - - principal_type - - principal_id - object_type - relation - created_at @@ -15,15 +18,6 @@ properties: project_id: type: string description: Project this grant is scoped to. - principal_type: - type: string - enum: - - user - - team - description: Kind of principal bound by this grant. - principal_id: - type: string - description: Principal id (`user_` or `team_`). object_type: type: string enum: @@ -50,32 +44,11 @@ properties: - type: 'null' user: description: | - Resolved identity of a user principal (ADR 058). Present when - `principal_type` is `user`; omitted for team grants. Degrades to - `user_id` only when the user can no longer be loaded. - $ref: ../../components/schemas/user-ref.yaml + Present on a user grant. Omit on a team grant. Always a user-ref + (`user_id`); expand adds envelope fields on this same object. + $ref: grant-user.yaml team: description: | - Resolved identity of a team principal. Present when `principal_type` - is `team`; omitted for user grants. Degrades to `team_id` only when - the team can no longer be loaded. This is a label ref, not the full - Team body. - $ref: ../../components/schemas/team-ref.yaml - principal: - readOnly: true - description: | - The principal named by `principal_id`, present only when the request - asked for it with `expand: ["principal"]` (ADR 059). Absent means it - was not requested; `null` means the principal cannot be loaded - (deleted or missing). GET and create never set this field. - - When present, the body is the same representation `GET /users/{id}` - serves for `principal_type=user`, or `GET /teams/{id}` for - `principal_type=team`. Discriminate with the grant's existing - `principal_type`. - - Requires `user.read` and `team.read` in addition to `project.read`. - Both are checked on the whole request before the list. - oneOf: - - $ref: grant-expanded-principal.yaml - - type: 'null' + Present on a team grant. Omit on a user grant. Always a team-ref + (`team_id`); expand adds envelope fields on this same object. + $ref: grant-team.yaml diff --git a/api/openapi/endpoints/grants/methods.yaml b/api/openapi/endpoints/grants/methods.yaml index eee509beb..77de95629 100644 --- a/api/openapi/endpoints/grants/methods.yaml +++ b/api/openapi/endpoints/grants/methods.yaml @@ -4,10 +4,12 @@ post: description: | Bind a user or team to `project.viewer`, `project.editor`, or `project.admin` on the project identified by the `project-id` header. - IDs are `asgn_`. Owning-team (`project.team`) grants are not - created here — claim owns that path. An unrevoked grant with the same - principal and relation occupies the unique key even after `expires_at`; - DELETE it before re-creating. + Name the principal with `user` (`user_id` or `identifier`) or `team` + (`team_id` or `name`). IDs are `asgn_`. Owning-team + (`project.team`) grants are not created here — claim owns that path. An + unrevoked grant with the same principal and relation occupies the unique + key even after `expires_at`; DELETE it before re-creating. + Create does not accept `expand`; the 201 `user` / `team` are refs only. tags: - Grants security: diff --git a/api/openapi/endpoints/grants/query/grant-expand.yaml b/api/openapi/endpoints/grants/query/grant-expand.yaml index 980e2ce9d..2d3b618c7 100644 --- a/api/openapi/endpoints/grants/query/grant-expand.yaml +++ b/api/openapi/endpoints/grants/query/grant-expand.yaml @@ -1,17 +1,17 @@ type: string description: | - A related object to embed on each returned grant (ADR 059). + Expand the bound user or team on each returned grant (ADR 059, grant + exception: extras are inlined onto `user` / `team`, not a sibling). - - `principal`: the principal named by `principal_id`, as `principal` on - each grant. The property is omitted entirely when not requested. When - requested, it is the same body `GET /users/{id}` serves for - `principal_type=user`, or `GET /teams/{id}` for `principal_type=team`, - and `null` when that principal cannot be loaded. Discriminate with the - grant's existing `principal_type`. + - `principal`: add User envelope fields (`schema`, `attributes`, + `metadata`) on `user`, or Team `status` / `created_at` / `updated_at` + on `team`. The ref (`user_id` / `team_id`) is always present. When the + principal cannot be loaded, the object stays a degraded ref — the same + shape as not expanding. Requires `user.read` and `team.read` in addition to `project.read`. Both are checked on the whole request before the list, because a mixed page is the common case. A caller who may not read either resource - receives 403 rather than a silently missing `principal`. + receives 403 rather than silently omitting extras. enum: - principal diff --git a/api/openapi/endpoints/grants/query/grant-filter-field.yaml b/api/openapi/endpoints/grants/query/grant-filter-field.yaml index a5bb41f7a..19c0e016c 100644 --- a/api/openapi/endpoints/grants/query/grant-filter-field.yaml +++ b/api/openapi/endpoints/grants/query/grant-filter-field.yaml @@ -2,13 +2,13 @@ type: string description: | Field to filter grants by: - `created_at`: RFC3339 timestamp - - `principal_type`: `user` or `team` - - `principal_id`: principal id (`user_` or `team_`) + - `user_id`: user principal id (`user_`) + - `team_id`: team principal id (`team_`) - `relation`: `viewer`, `editor`, or `admin` - `expires_at`: RFC3339 timestamp (null when the grant does not expire) enum: - created_at - - principal_type - - principal_id + - user_id + - team_id - relation - expires_at diff --git a/api/openapi/endpoints/grants/query/methods.yaml b/api/openapi/endpoints/grants/query/methods.yaml index d592a5141..8d49a5b59 100644 --- a/api/openapi/endpoints/grants/query/methods.yaml +++ b/api/openapi/endpoints/grants/query/methods.yaml @@ -8,10 +8,10 @@ post: DELETE before re-granting. Project-secret setup (`sk_proj`) and owning-team (`relation=team`) rows are not returned. Grants are not in `resource_scope_index`; project scope is required on the query (same as - get). Requires `project.read`. `expand: ["principal"]` additionally - requires `user.read` and `team.read` (documented on the expand enum; - those scopes cannot be ANDed onto this security block because they are - body-conditional). + get). Requires `project.read`. `expand: ["principal"]` adds envelope + fields on `user` / `team` and additionally requires `user.read` and + `team.read` (documented on the expand enum; those scopes cannot be ANDed + onto this security block because they are body-conditional). tags: - Grants security: diff --git a/api/openapi/endpoints/grants/query/query-grants-request.yaml b/api/openapi/endpoints/grants/query/query-grants-request.yaml index fb731b345..92692af6c 100644 --- a/api/openapi/endpoints/grants/query/query-grants-request.yaml +++ b/api/openapi/endpoints/grants/query/query-grants-request.yaml @@ -14,8 +14,9 @@ properties: expand: type: array description: | - Related objects to embed on each grant (ADR 059). Omit it and no - embedded object is returned. An unrecognised value is rejected. + Extra principal fields to add onto each grant's `user` or `team` + (ADR 059 grant exception). Omit it and those objects stay refs. An + unrecognised value is rejected. uniqueItems: true items: $ref: grant-expand.yaml diff --git a/api/openapi/endpoints/grants/team-locator.yaml b/api/openapi/endpoints/grants/team-locator.yaml new file mode 100644 index 000000000..b3a738791 --- /dev/null +++ b/api/openapi/endpoints/grants/team-locator.yaml @@ -0,0 +1,18 @@ +title: TeamLocator +description: | + Name a team with exactly one of `team_id` or `name`. Sending both, + neither, or any other field is `grant.invalid`. +type: object +additionalProperties: false +properties: + team_id: + $ref: ../../components/schemas/team-id.yaml + description: | + Platform-homed team id (`team_`). The team must be active. + name: + type: string + minLength: 1 + description: | + Team name, unique per project case-insensitively. Looked up in + the platform project. The team must be active. + example: Acme AI Admins diff --git a/api/openapi/endpoints/grants/user-locator.yaml b/api/openapi/endpoints/grants/user-locator.yaml new file mode 100644 index 000000000..b05e1ee5f --- /dev/null +++ b/api/openapi/endpoints/grants/user-locator.yaml @@ -0,0 +1,19 @@ +title: UserLocator +description: | + Name a user with exactly one of `user_id` or `identifier`. Sending both, + neither, or any other field is `grant.invalid`. +type: object +additionalProperties: false +properties: + user_id: + $ref: ../../components/schemas/user-id.yaml + description: | + Platform-homed user id (`user_`). The user must be active. + identifier: + type: string + minLength: 1 + description: | + The user schema's designated identifier (`x-identifier`), looked + up in the platform project. Exactly one active match is required; + zero or several resolve as not found. + example: alice@acme.com diff --git a/apps/console/src/components/add-admin-dialog.tsx b/apps/console/src/components/add-admin-dialog.tsx index 5a51a50b4..214060577 100644 --- a/apps/console/src/components/add-admin-dialog.tsx +++ b/apps/console/src/components/add-admin-dialog.tsx @@ -30,7 +30,7 @@ import { getConsoleProjectId } from "../runtime/runtime"; /** * Give an existing person admin access to this project (#769). * - * **The colleague must already have signed up.** A grant binds a `principal_id`, + * **The colleague must already have signed up.** A grant binds a `user_id`, * so there is nobody to bind until the account exists — which is why this picks * from people rather than taking an email like the design's `Invite admin` * frame. #769 scopes it the same way: the signup link is shared separately, and @@ -143,7 +143,7 @@ function AddAdminForm({ setError(undefined); try { await api.createGrant( - { principal_type: "user", principal_id: selected.id, relation: "admin" }, + { user: { user_id: selected.id }, relation: "admin" }, { project_id: getConsoleProjectId() }, ); toast.success(`${selected.label} added`, { diff --git a/apps/console/src/routes/_authed/settings/admins.spec.tsx b/apps/console/src/routes/_authed/settings/admins.spec.tsx index d86aec91a..18b432d0a 100644 --- a/apps/console/src/routes/_authed/settings/admins.spec.tsx +++ b/apps/console/src/routes/_authed/settings/admins.spec.tsx @@ -42,22 +42,21 @@ function grant(overrides: Record = {}) { return { id: "asgn_1", project_id: "proj_1", - principal_type: "user", - principal_id: "user_1", object_type: "project", relation: "admin", created_at: "2026-09-01T10:00:00Z", + user: { user_id: "user_1" }, ...overrides, }; } /** - * A user principal as `expand: ["principal"]` embeds it: the same body - * `GET /users/{id}` serves, so the envelope travels with the identity fields. + * A user as `expand: ["principal"]` inlines it: extras live on the same + * `user` object as the ref (`user_id`, never `id`). */ -function userPrincipal(identity: Record) { +function grantUser(identity: Record) { return { - id: "user_1", + user_id: "user_1", schema: "sch_1", attributes: {}, metadata: { @@ -86,11 +85,10 @@ describe("admins screen", () => { // One request, not a read per row: the principal rides along on the list // (ADR 059), and ADR 058's resolved identity is what the row shows. const bodies = stubGrants( - grant({ principal: userPrincipal({ display: "Maya Patel", identifier: "maya@acme.com" }) }), + grant({ user: grantUser({ display: "Maya Patel", identifier: "maya@acme.com" }) }), grant({ id: "asgn_2", - principal_id: "user_2", - principal: userPrincipal({ id: "user_2", identifier: "sol@acme.com" }), + user: grantUser({ user_id: "user_2", identifier: "sol@acme.com" }), }), ); await renderAdmins(); @@ -103,8 +101,8 @@ describe("admins screen", () => { }); it("falls back to the principal id when the principal cannot be loaded", async () => { - // `principal: null` is what a deleted user's surviving grant looks like. - stubGrants(grant({ principal: null })); + // A deleted user's surviving grant is a degraded ref: `user_id` only. + stubGrants(grant({ user: { user_id: "user_1" } })); await renderAdmins(); const table = within(await screen.findByRole("table")); @@ -114,7 +112,7 @@ describe("admins screen", () => { it("renders whatever relation a grant carries, not just admin", async () => { // The screen only creates `admin`, but the catalog has three relations and // a grant made elsewhere must not be mislabelled. - stubGrants(grant({ relation: "viewer", principal: userPrincipal({ identifier: "vi@acme.com" }) })); + stubGrants(grant({ relation: "viewer", user: grantUser({ identifier: "vi@acme.com" }) })); await renderAdmins(); const table = within(await screen.findByRole("table")); @@ -122,14 +120,12 @@ describe("admins screen", () => { }); it("labels a team principal by its name", async () => { - // A team grant carries the team body, which has a `name` and no identity - // chain. `principal_type` is what tells the two apart. + // A team grant carries `team` and omits `user`. `name` is already on the ref. stubGrants( grant({ - principal_type: "team", - principal_id: "team_1", - principal: { - id: "team_1", + user: undefined, + team: { + team_id: "team_1", name: "Platform", status: "active", created_at: "2026-09-01T10:00:00Z", @@ -187,8 +183,7 @@ describe("admins screen", () => { // Bound to the person, at the only level this journey grants (#769). await waitFor(() => expect(created).toEqual({ - principal_type: "user", - principal_id: "user_9", + user: { user_id: "user_9" }, relation: "admin", }), ); @@ -197,7 +192,7 @@ describe("admins screen", () => { it("does not offer people who are already admins", async () => { // `POST /grants` refuses a second grant for the same principal and relation, // so offering them would be offering a choice that cannot work. - stubGrants(grant({ principal_id: "user_9", principal: userPrincipal({ id: "user_9" }) })); + stubGrants(grant({ user: grantUser({ user_id: "user_9" }) })); server.use( http.post(USERS_QUERY_URL, () => HttpResponse.json({ @@ -223,8 +218,7 @@ describe("admins screen", () => { stubGrants( grant({ relation: "viewer", - principal_id: "user_9", - principal: userPrincipal({ id: "user_9" }), + user: grantUser({ user_id: "user_9" }), }), ); server.use( @@ -274,7 +268,7 @@ describe("admins screen", () => { // carries, and "Remove admin" over a `viewer` row would misdescribe the // click. stubGrants( - grant({ relation: "viewer", principal: userPrincipal({ display: "Sasha Kim" }) }), + grant({ relation: "viewer", user: grantUser({ display: "Sasha Kim" }) }), ); await renderAdmins(); @@ -287,7 +281,7 @@ describe("admins screen", () => { it("does not carry a failed removal into the next opening", async () => { // The dialog content is mounted per opening, so the error from one attempt // is not sitting there when the operator opens it again. - stubGrants(grant({ principal: userPrincipal({ display: "Maya Patel" }) })); + stubGrants(grant({ user: grantUser({ display: "Maya Patel" }) })); server.use( http.delete(`${GRANTS_URL}/:id`, () => HttpResponse.json({ code: "grant.not_found", message: "no such grant" }, { status: 404 }), @@ -332,7 +326,7 @@ describe("admins screen", () => { }); it("removes an admin after confirming", async () => { - stubGrants(grant({ principal: userPrincipal({ display: "Maya Patel" }) })); + stubGrants(grant({ user: grantUser({ display: "Maya Patel" }) })); let deleted: string | undefined; server.use( http.delete(`${GRANTS_URL}/:id`, ({ params }) => { @@ -352,7 +346,7 @@ describe("admins screen", () => { }); it("keeps the row when the removal is cancelled", async () => { - stubGrants(grant({ principal: userPrincipal({ display: "Maya Patel" }) })); + stubGrants(grant({ user: grantUser({ display: "Maya Patel" }) })); let deleteCalls = 0; server.use( http.delete(`${GRANTS_URL}/:id`, () => { diff --git a/apps/console/src/routes/_authed/settings/admins.tsx b/apps/console/src/routes/_authed/settings/admins.tsx index 2809bac12..2433ca354 100644 --- a/apps/console/src/routes/_authed/settings/admins.tsx +++ b/apps/console/src/routes/_authed/settings/admins.tsx @@ -36,7 +36,7 @@ import { getConsoleProjectId } from "../../../runtime/runtime"; * * **Not an invite flow.** The design draws `Invite`, a `Pending` status and * revoke/resend actions, but a grant is only ever created against a person who - * already exists: `POST /grants` takes a `principal_id`, and the grant resource + * already exists: `POST /grants` takes a user locator, and the grant resource * carries no status field, so there is no pending state to render and nothing * to revoke before signup. #769 says the same in its scope — the colleague signs * up through a separately shared link, and access is given afterwards. The @@ -99,7 +99,8 @@ function AdminsScreen() { // still be made an admin. const alreadyAdmins = grants .filter((grant) => grant.relation === "admin") - .map((grant) => grant.principal_id); + .map((grant) => grant.user?.user_id ?? grant.team?.team_id) + .filter((id): id is string => Boolean(id)); return (
@@ -159,32 +160,33 @@ function AdminsScreen() { /** * How a grant is labelled. * - * `expand: ["principal"]` embeds the principal so the table needs no read per - * row. Every fallback below is real: the property is absent when the expansion - * was refused, `null` when the principal cannot be loaded (a deleted user - * leaves its grant behind), and a user's identity fields are themselves - * optional, since ADR 058 lets a schema designate neither a display nor an - * identifier. The chain ends at the id, which always exists — and which is what - * the operator needs to know which grant they are revoking. + * `expand: ["principal"]` copies envelope fields onto the same `user` / `team` + * ref so the table needs no read per row. A deleted user leaves its grant + * behind as a degraded ref (`user_id` / `team_id` only). A user's identity + * fields are themselves optional, since ADR 058 lets a schema designate + * neither a display nor an identifier. The chain ends at that id, which + * always exists — and which is what the operator needs to know which grant + * they are revoking. * - * The embedded body is a user or a team, discriminated by `principal_type` as - * the contract says. They are separate union members and the type ties neither - * to that field, so the values are read the way the console reads every other - * open record: defensively, by name. + * Discriminate on which of `user` or `team` is present. They are separate + * objects and the type ties neither to a kind field, so the values are read + * the way the console reads every other open record: defensively, by name. */ function toAdminRow(grant: Grant): AdminRow { return { id: grant.id, - name: principalName(grant) ?? grant.principal_id, + name: principalName(grant) ?? grant.user?.user_id ?? grant.team?.team_id ?? grant.id, level: grant.relation.charAt(0).toUpperCase() + grant.relation.slice(1), }; } function principalName(grant: Grant): string | undefined { - if (!grant.principal) return undefined; - const principal = grant.principal as unknown as Record; - if (grant.principal_type === "team") return field(principal, "name"); - return field(principal, "display") ?? field(principal, "identifier"); + if (grant.team) { + return grant.team.name; + } + if (!grant.user) return undefined; + const user = grant.user as unknown as Record; + return field(user, "display") ?? field(user, "identifier"); } /** diff --git a/docs/adrs/054-customer-collaboration-grants.md b/docs/adrs/054-customer-collaboration-grants.md index 5d21d2bbb..27dff1c81 100644 --- a/docs/adrs/054-customer-collaboration-grants.md +++ b/docs/adrs/054-customer-collaboration-grants.md @@ -217,11 +217,14 @@ An agency or consultancy uses its own platform-project team as the principal on customer-issued grants. A user may belong to several customer or agency access teams. Team IDs are stable addressing identifiers. -For v1, the customer-facing collaboration API accepts either a user ID or team -ID from the platform project. It validates that the supplied principal exists -and is active without exposing a global directory. Direct grants are useful for -one-off access; access teams are preferred when several people share the same -project set or when one membership removal should revoke several derived roles. +For v1, the customer-facing collaboration API accepts an id **or** a unique +locator from the platform project: `user_id` / `identifier` for a user, and +`team_id` / `name` for a team. It resolves that locator to a stored principal +and validates that the principal exists and is active, without exposing a +global directory. Storage and `authz.granted` / `authz.revoked` events stay +`(principal_type, principal_id)`. Direct grants are useful for one-off access; +access teams are preferred when several people share the same project set or +when one membership removal should revoke several derived roles. Both primitives are model commitments from day one: the row shape is identical, and the testing obligations cover the direct and team-derived paths regardless @@ -306,9 +309,9 @@ Put plainly, the assignment says **who has which project role**; the event says **who changed that assignment**. Customer-issued grants need no agency-side request flow in v1. The customer -names the target user or team ID and remains responsible for granting and -revoking access. Invitations, approval handshakes, and a discoverable agency -directory are later product surfaces. +names the target user or team (by id or unique locator) and remains +responsible for granting and revoking access. Invitations, approval +handshakes, and a discoverable agency directory are later product surfaces. ### 7. Project creation and claim name the target team diff --git a/docs/adrs/059-expanding-embedded-objects.md b/docs/adrs/059-expanding-embedded-objects.md index 499ff3029..579c0ec21 100644 --- a/docs/adrs/059-expanding-embedded-objects.md +++ b/docs/adrs/059-expanding-embedded-objects.md @@ -137,6 +137,33 @@ present on every user of the page today, and #420 is what decides the answer for a row whose owner the caller may not read. The wire description says as much, so the pair is not read as exhaustive. +## Grant expansion + +`POST /grants/query` and `GET /grants/{id}` accept `expand: ["principal"]`. +That flag does **not** add a sibling `principal` property. Extra fields are +copied onto the existing `user` or `team` ref so the discriminator stays which +of those keys is present (`user_id` / `team_id`, never `id`). Create does +not take expand; a 201 response is ref-only. + +This is an exception to rules 3 and 4: + +- There is no omitted-vs-`null` expand property. Clients that need “I asked + and it was gone” cannot see that on the wire. +- The extras are not `$ref` GET `/users/{id}` (that schema uses `id`). User + extras are `schema` / `attributes` / `metadata`; team extras are `status` / + `created_at` / `updated_at`. User-list expands (`teams`, + `lifecycle_owner_team`) are not copied onto a grant. + +When expand was asked and the person cannot be loaded, `user` / `team` +stay the degraded ref (`user_id` / `team_id` only) — the same shape as not +expanding a missing principal. + +GET-by-id takes the same `expand` query param as the list. The dual +`user.read` **and** `team.read` gate stays: both are checked on the whole +request before the assignment is loaded (operator `project.write` still +satisfies both). A mixed page of user and team grants is the common case, so +the two permissions are not split per row. + ## Open questions - **Sparse fieldsets.** Selecting a subset of the embedded object's properties diff --git a/docs/adrs/README.md b/docs/adrs/README.md index 3427c64f3..b9dc918c2 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -64,5 +64,5 @@ This directory contains architecture decision records (ADRs) for nextgen. | [056](056-passkey-registration-as-auth-check.md) | Passkey registration is an auth-attempt check | Accepted | Resolves #220: the registration ceremony becomes an `AuthCheckTypePasskeyRegistration` check on the attempt + checks machinery; the `passkey_registrations` stack and `RegisterCreatedUser` synthetic check are deleted; the finish leg lands user row, credential, user factor, and check success in one transaction; completed enrollments merge into the session as passkey-class factors, and password sign-up records real user + password factors symmetrically. | | [057](057-login-customization-categories.md) | Login Customization Categories and Ownership | Proposed | Five customization categories each have one home. Embedded, Zitadel-served, and fully custom are choices, not a ladder. First iteration is branding settings (#936); translations is a different setting (#1038). Widget template is later shared structure; Zitadel-served page chrome is unset. Long form in [`../design/branding/customization-strategy.md`](../design/branding/customization-strategy.md). | | [058](058-user-identity-designation-and-references.md) | User Identity Designation and References | Proposed | User schemas declare identity: `x-identifier` names the one project-unique property that identifies a user (conditionally required when an auth method needs identifier-first dispatch), `x-display` names the properties rendering the friendly name. A shared `user-ref` (`user_id`, `identifier`, `identifier_property`, `display`) replaces per-endpoint convention fields on every user-linked resource, resolved live and batched; the user envelope carries the same derived fields (amends ADR 052). Identifier lookups are scoped to designated, unique rows of in-scope schemas; a bare `login_name` resolves across schemas by exactly-one-match, never precedence; designated values are unique across designations (staged enforcement). Retires the `IdentityAttributeKeys` spelling convention; solution design for #956, reframes #869. | -| [059](059-expanding-embedded-objects.md) | Expanding Embedded Objects | Proposed | Opt-in `expand` on list queries embeds a related object inline, to-many or to-one: closed enum per endpoint, omitted-vs-`[]`/`null` distinguishing "did not ask" from "none", one schema shared with the sub-resource, a hard cap plus truncation flag for collections, and a batched hydrate rather than a join (a join breaks `LIMIT` and the keyset cursor). Each relation is gated under its own permission and none affects ordering or page tokens. First uses are `expand: ["teams"]` and `expand: ["lifecycle_owner_team"]` on `POST /users/query`. Sparse fieldsets and nested expansion are out of scope. | +| [059](059-expanding-embedded-objects.md) | Expanding Embedded Objects | Proposed | Opt-in `expand` on list queries embeds a related object inline, to-many or to-one: closed enum per endpoint, omitted-vs-`[]`/`null` distinguishing "did not ask" from "none", one schema shared with the sub-resource, a hard cap plus truncation flag for collections, and a batched hydrate rather than a join (a join breaks `LIMIT` and the keyset cursor). Each relation is gated under its own permission and none affects ordering or page tokens. First uses are `expand: ["teams"]` and `expand: ["lifecycle_owner_team"]` on `POST /users/query`. Grant `expand: ["principal"]` inlines extras onto the existing `user` / `team` ref (exception to omitted-vs-null and shared `$ref`; GET-by-id takes the same query param). Sparse fieldsets and nested expansion are out of scope. | | [060](060-session-team-lifecycle-join.md) | Sessions Join Teams Through Lifecycle Ownership | Accepted | Resolves #749: a session stays project-scoped and joins the team that owns its bound user's lifecycle (`users.lifecycle_owner_team_id`, ADR 024), derived at read time and never stored — so every row a team sees is a session it may revoke. Roster membership is explicitly not the join: it would list users the team cannot act on, miss managed users off the roster, and need a `team_membership.read` gate this endpoint does not otherwise need. The wire field is `lifecycle_owner_team_id`, leaving `team_id` free for the explicit binding; it compiles to a correlated `EXISTS` over `users`, filter-only and `equals`-only. Measured at 2M sessions: the read path needed two `sessions` indexes (migration 000019), both load-bearing because they cover opposite team selectivities. Consequence: revocation stays **project-level only** — team-scoped revocation and scoped agent sessions need the explicit `team_sessions` binding, deferred to #975. | diff --git a/internal/api/grant.go b/internal/api/grant.go index feb208831..72f6836f3 100644 --- a/internal/api/grant.go +++ b/internal/api/grant.go @@ -14,19 +14,10 @@ func (h *Handler) CreateGrant(ctx context.Context, req *api.CreateGrantRequest, if err := h.requireProjectAccess(ctx, string(params.ProjectID), grantAccess, opWrite); err != nil { return nil, err } - principalType, err := grantPrincipalType(req.PrincipalType) + input, err := createGrantInput(string(params.ProjectID), req) if err != nil { return nil, err } - input := service.CreateGrantInput{ - ProjectID: string(params.ProjectID), - PrincipalType: principalType, - PrincipalID: req.PrincipalID, - Relation: string(req.Relation), - } - if v, ok := req.ExpiresAt.Get(); ok { - input.ExpiresAt = &v - } grant, err := h.grantService.Create(ctx, input) if err != nil { return nil, err @@ -38,7 +29,20 @@ func (h *Handler) GetGrant(ctx context.Context, params api.GetGrantParams) (api. if err := h.requireProjectAccess(ctx, string(params.ProjectID), grantAccess, opRead); err != nil { return nil, err } - grant, err := h.grantService.Get(ctx, string(params.ProjectID), params.ID) + includePrincipal := slices.Contains(params.Expand, api.GrantExpandPrincipal) + if includePrincipal { + // Constructors stay in this function so gen_openapi_errors can see + // user.permission_denied / team.permission_denied on the operation. + if !hasGranularOrOperator(ctx, "user.read") { + return nil, domain.ErrUserPermissionDenied(). + WithMessage("expanding a grant principal requires user.read") + } + if !hasGranularOrOperator(ctx, "team.read") { + return nil, domain.ErrTeamPermissionDenied(). + WithMessage("expanding a grant principal requires team.read") + } + } + grant, err := h.grantService.Get(ctx, string(params.ProjectID), params.ID, includePrincipal) if err != nil { return nil, err } @@ -107,66 +111,130 @@ func (h *Handler) DeleteGrant(ctx context.Context, params api.DeleteGrantParams) return &api.DeleteGrantNoContent{}, nil } +func createGrantInput(projectID string, req *api.CreateGrantRequest) (service.CreateGrantInput, error) { + input := service.CreateGrantInput{ + ProjectID: projectID, + Relation: string(req.Relation), + } + if v, ok := req.ExpiresAt.Get(); ok { + input.ExpiresAt = &v + } + user, hasUser := req.User.Get() + team, hasTeam := req.Team.Get() + if hasUser == hasTeam { + return input, domain.ErrGrantInvalid().WithDetails("exactly one of user or team is required") + } + if hasUser { + userID, hasID := user.UserID.Get() + identifier, hasIdentifier := user.Identifier.Get() + if hasID == hasIdentifier { + return input, domain.ErrGrantInvalid().WithDetails("user requires exactly one of user_id or identifier") + } + if hasID { + input.UserID = string(userID) + } else { + input.Identifier = identifier + } + return input, nil + } + teamID, hasID := team.TeamID.Get() + name, hasName := team.Name.Get() + if hasID == hasName { + return input, domain.ErrGrantInvalid().WithDetails("team requires exactly one of team_id or name") + } + if hasID { + input.TeamID = string(teamID) + } else { + input.TeamName = name + } + return input, nil +} + func grantResponse(g *service.Grant) (*api.Grant, error) { if g == nil || g.Assignment == nil { return nil, domain.ErrGrantNotFound() } asgn := g.Assignment resp := &api.Grant{ - ID: asgn.ID, - ProjectID: asgn.ProjectID, - PrincipalType: api.GrantPrincipalType(asgn.PrincipalType.String()), - PrincipalID: asgn.PrincipalID, - ObjectType: api.GrantObjectTypeProject, - Relation: api.GrantRelation(asgn.Relation), - CreatedAt: asgn.CreatedAt, + ID: asgn.ID, + ProjectID: asgn.ProjectID, + ObjectType: api.GrantObjectTypeProject, + Relation: api.GrantRelation(asgn.Relation), + CreatedAt: asgn.CreatedAt, } if asgn.ExpiresAt != nil { resp.ExpiresAt = api.NewOptNilDateTime(*asgn.ExpiresAt) } - if g.User != nil { - resp.User = api.NewOptUserRef(userRefToAPI(*g.User)) - } - if g.Team != nil { - ref := api.TeamRef{TeamID: g.Team.TeamID} - if g.Team.Name != "" { - ref.Name = api.NewOptString(g.Team.Name) - } - resp.Team = api.NewOptTeamRef(ref) - } - if g.Principal != nil { - if err := setGrantPrincipal(resp, g.Principal); err != nil { + switch asgn.PrincipalType { + case domain.AuthzPrincipalTypeUser: + user, err := grantUserResponse(g) + if err != nil { return nil, err } + resp.User.SetTo(user) + case domain.AuthzPrincipalTypeTeam: + resp.Team.SetTo(grantTeamResponse(g)) } return resp, nil } -func setGrantPrincipal(resp *api.Grant, principal *service.GrantPrincipal) error { - switch { - case principal.User != nil: - u, err := domainUserToApiUser(principal.User) - if err != nil { - return err - } - resp.Principal.SetTo(api.NewUserGrantExpandedPrincipal(*u)) - case principal.Team != nil: - resp.Principal.SetTo(api.NewTeamResponseGrantExpandedPrincipal(*teamResponse(principal.Team))) - default: - resp.Principal.SetToNull() +func grantUserResponse(g *service.Grant) (api.GrantUser, error) { + ref := domain.UserRef{UserID: g.Assignment.PrincipalID} + if g.User != nil { + ref = *g.User + } + out := api.GrantUser{UserID: api.UserID(ref.UserID)} + if ref.Identifier != "" { + out.Identifier = api.NewOptString(ref.Identifier) + out.IdentifierProperty = api.NewOptString(ref.IdentifierProperty) + } + if ref.Display != "" { + out.Display = api.NewOptString(ref.Display) + } + if g.Principal == nil || g.Principal.User == nil { + return out, nil + } + u := g.Principal.User + out.Schema.SetTo(u.SchemaURL) + userData, err := u.Attributes.ToMap() + if err != nil { + return out, domain.ErrInternal(err).WithMessage("failed to parse user attributes") } - return nil + attributes, err := convertUsingJson[api.GrantUserAttributes](userData) + if err != nil { + return out, err + } + out.Attributes.SetTo(*attributes) + var lifecycleOwnerTeamID api.OptNilString + if teamID, ok := u.OwningTeamID(); ok { + lifecycleOwnerTeamID.SetTo(teamID) + } else { + lifecycleOwnerTeamID.SetToNull() + } + out.Metadata.SetTo(api.UserMetadata{ + CreatedAt: u.Metadata.CreatedAt, + UpdatedAt: u.Metadata.UpdatedAt, + Status: api.UserMetadataStatus(u.Metadata.Status), + LifecycleOwnerTeamID: lifecycleOwnerTeamID, + }) + return out, nil } -func grantPrincipalType(t api.CreateGrantRequestPrincipalType) (domain.AuthzPrincipalType, error) { - switch t { - case api.CreateGrantRequestPrincipalTypeUser: - return domain.AuthzPrincipalTypeUser, nil - case api.CreateGrantRequestPrincipalTypeTeam: - return domain.AuthzPrincipalTypeTeam, nil - default: - return "", domain.ErrGrantInvalid().WithDetails("principal_type must be user or team") +func grantTeamResponse(g *service.Grant) api.GrantTeam { + out := api.GrantTeam{TeamID: g.Assignment.PrincipalID} + if g.Team != nil { + out.TeamID = g.Team.TeamID + if g.Team.Name != "" { + out.Name = api.NewOptString(g.Team.Name) + } + } + if g.Principal != nil && g.Principal.Team != nil { + t := g.Principal.Team + out.Status.SetTo(teamStatus(t.Status)) + out.CreatedAt.SetTo(t.CreatedAt) + out.UpdatedAt.SetTo(t.UpdatedAt) } + return out } func grantErrorResponse(err domain.Error) *api.ErrorDetailsStatusCode { diff --git a/internal/api/grant_internal_test.go b/internal/api/grant_internal_test.go index 07dce7574..940417172 100644 --- a/internal/api/grant_internal_test.go +++ b/internal/api/grant_internal_test.go @@ -28,7 +28,115 @@ func TestMapQueryGrantsToService_Expand(t *testing.T) { } } -func TestGrantResponse_Principal(t *testing.T) { +func TestCreateGrantInput_Locators(t *testing.T) { + t.Run("user id", func(t *testing.T) { + got, err := createGrantInput("proj_a", &api.CreateGrantRequest{ + Relation: api.CreateGrantRequestRelationViewer, + User: api.NewOptUserLocator(api.UserLocator{ + UserID: api.NewOptUserID("user_1"), + }), + }) + if err != nil { + t.Fatal(err) + } + if got.UserID != "user_1" || got.Identifier != "" || got.TeamID != "" { + t.Fatalf("got %+v", got) + } + }) + t.Run("user identifier", func(t *testing.T) { + got, err := createGrantInput("proj_a", &api.CreateGrantRequest{ + Relation: api.CreateGrantRequestRelationAdmin, + User: api.NewOptUserLocator(api.UserLocator{ + Identifier: api.NewOptString("alice@acme.com"), + }), + }) + if err != nil { + t.Fatal(err) + } + if got.Identifier != "alice@acme.com" || got.UserID != "" { + t.Fatalf("got %+v", got) + } + }) + t.Run("team id", func(t *testing.T) { + got, err := createGrantInput("proj_a", &api.CreateGrantRequest{ + Relation: api.CreateGrantRequestRelationEditor, + Team: api.NewOptTeamLocator(api.TeamLocator{ + TeamID: api.NewOptTeamID("team_1"), + }), + }) + if err != nil { + t.Fatal(err) + } + if got.TeamID != "team_1" || got.TeamName != "" { + t.Fatalf("got %+v", got) + } + }) + t.Run("team name", func(t *testing.T) { + got, err := createGrantInput("proj_a", &api.CreateGrantRequest{ + Relation: api.CreateGrantRequestRelationAdmin, + Team: api.NewOptTeamLocator(api.TeamLocator{ + Name: api.NewOptString("Acme AI Admins"), + }), + }) + if err != nil { + t.Fatal(err) + } + if got.TeamName != "Acme AI Admins" || got.TeamID != "" { + t.Fatalf("got %+v", got) + } + }) + t.Run("neither user nor team", func(t *testing.T) { + _, err := createGrantInput("proj_a", &api.CreateGrantRequest{Relation: api.CreateGrantRequestRelationViewer}) + if !errors.Is(err, domain.ErrGrantInvalid()) { + t.Fatalf("error = %v, want grant.invalid", err) + } + }) + t.Run("both user and team", func(t *testing.T) { + _, err := createGrantInput("proj_a", &api.CreateGrantRequest{ + Relation: api.CreateGrantRequestRelationViewer, + User: api.NewOptUserLocator(api.UserLocator{UserID: api.NewOptUserID("user_1")}), + Team: api.NewOptTeamLocator(api.TeamLocator{TeamID: api.NewOptTeamID("team_1")}), + }) + if !errors.Is(err, domain.ErrGrantInvalid()) { + t.Fatalf("error = %v, want grant.invalid", err) + } + }) + t.Run("user both fields", func(t *testing.T) { + _, err := createGrantInput("proj_a", &api.CreateGrantRequest{ + Relation: api.CreateGrantRequestRelationViewer, + User: api.NewOptUserLocator(api.UserLocator{ + UserID: api.NewOptUserID("user_1"), + Identifier: api.NewOptString("alice@acme.com"), + }), + }) + if !errors.Is(err, domain.ErrGrantInvalid()) { + t.Fatalf("error = %v, want grant.invalid", err) + } + }) + t.Run("team both fields", func(t *testing.T) { + _, err := createGrantInput("proj_a", &api.CreateGrantRequest{ + Relation: api.CreateGrantRequestRelationViewer, + Team: api.NewOptTeamLocator(api.TeamLocator{ + TeamID: api.NewOptTeamID("team_1"), + Name: api.NewOptString("Acme AI Admins"), + }), + }) + if !errors.Is(err, domain.ErrGrantInvalid()) { + t.Fatalf("error = %v, want grant.invalid", err) + } + }) + t.Run("empty user locator", func(t *testing.T) { + _, err := createGrantInput("proj_a", &api.CreateGrantRequest{ + Relation: api.CreateGrantRequestRelationViewer, + User: api.NewOptUserLocator(api.UserLocator{}), + }) + if !errors.Is(err, domain.ErrGrantInvalid()) { + t.Fatalf("error = %v, want grant.invalid", err) + } + }) +} + +func TestGrantResponse_UserAndTeam(t *testing.T) { asgn := &domain.AuthzAssignment{ ID: "asgn_1", ProjectID: "proj_a", @@ -37,28 +145,59 @@ func TestGrantResponse_Principal(t *testing.T) { ObjectType: "project", Relation: "viewer", } - userRef := &domain.UserRef{UserID: "user_1"} + userRef := &domain.UserRef{UserID: "user_1", Identifier: "alice@acme.com", IdentifierProperty: "email"} - t.Run("omit when Principal is nil", func(t *testing.T) { + t.Run("ref only when Principal is nil", func(t *testing.T) { resp, err := grantResponse(&service.Grant{Assignment: asgn, User: userRef}) if err != nil { t.Fatal(err) } - if resp.Principal.IsSet() { - t.Fatal("principal should be omitted") + if !resp.User.IsSet() { + t.Fatal("user should be set") + } + if resp.User.Value.UserID != "user_1" { + t.Fatalf("user_id = %s", resp.User.Value.UserID) + } + if resp.User.Value.Schema.IsSet() { + t.Fatal("schema should be omitted without expand") + } + if resp.Team.IsSet() { + t.Fatal("team should be omitted on a user grant") } }) - t.Run("null when Principal is empty", func(t *testing.T) { + t.Run("expand extras on user when Principal is loaded", func(t *testing.T) { resp, err := grantResponse(&service.Grant{ Assignment: asgn, User: userRef, + Principal: &service.GrantPrincipal{ + User: &domain.User{ + ID: "user_1", + SchemaURL: "sch_1", + Metadata: domain.UserMetadata{Status: domain.UserStatusActive}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if !resp.User.Value.Schema.IsSet() || resp.User.Value.Schema.Value != "sch_1" { + t.Fatalf("schema = %+v", resp.User.Value.Schema) + } + }) + t.Run("degraded ref when expand asked but user missing", func(t *testing.T) { + resp, err := grantResponse(&service.Grant{ + Assignment: asgn, + User: &domain.UserRef{UserID: "user_1"}, Principal: &service.GrantPrincipal{}, }) if err != nil { t.Fatal(err) } - if !resp.Principal.IsSet() || !resp.Principal.IsNull() { - t.Fatalf("principal set=%v null=%v, want set+null", resp.Principal.IsSet(), resp.Principal.IsNull()) + if resp.User.Value.UserID != "user_1" { + t.Fatalf("user_id = %s", resp.User.Value.UserID) + } + if resp.User.Value.Schema.IsSet() { + t.Fatal("schema should stay off a degraded ref") } }) t.Run("nil grant", func(t *testing.T) { diff --git a/internal/api/integration_test/grant_test.go b/internal/api/integration_test/grant_test.go index 0e4aeb9b4..a766c85b7 100644 --- a/internal/api/integration_test/grant_test.go +++ b/internal/api/integration_test/grant_test.go @@ -18,6 +18,42 @@ import ( "github.com/zitadel/nextgen/internal/service" ) +func userIDGrant(userID string, rel api.CreateGrantRequestRelation) *api.CreateGrantRequest { + return &api.CreateGrantRequest{ + Relation: rel, + User: api.NewOptUserLocator(api.UserLocator{ + UserID: api.NewOptUserID(api.UserID(userID)), + }), + } +} + +func teamIDGrant(teamID string, rel api.CreateGrantRequestRelation) *api.CreateGrantRequest { + return &api.CreateGrantRequest{ + Relation: rel, + Team: api.NewOptTeamLocator(api.TeamLocator{ + TeamID: api.NewOptTeamID(api.TeamID(teamID)), + }), + } +} + +func userIdentifierGrant(identifier string, rel api.CreateGrantRequestRelation) *api.CreateGrantRequest { + return &api.CreateGrantRequest{ + Relation: rel, + User: api.NewOptUserLocator(api.UserLocator{ + Identifier: api.NewOptString(identifier), + }), + } +} + +func teamNameGrant(name string, rel api.CreateGrantRequestRelation) *api.CreateGrantRequest { + return &api.CreateGrantRequest{ + Relation: rel, + Team: api.NewOptTeamLocator(api.TeamLocator{ + Name: api.NewOptString(name), + }), + } +} + func TestGrantCreateGetRevoke(t *testing.T) { t.Parallel() @@ -37,25 +73,19 @@ func TestGrantCreateGetRevoke(t *testing.T) { t.Parallel() userID := harness.CreateUserWithTeam(t, platform.ID) - createResp, err := client.CreateGrant(t.Context(), &api.CreateGrantRequest{ - PrincipalType: api.CreateGrantRequestPrincipalTypeUser, - PrincipalID: userID, - Relation: api.CreateGrantRequestRelationViewer, - }, params()) + createResp, err := client.CreateGrant(t.Context(), userIDGrant(userID, api.CreateGrantRequestRelationViewer), params()) require.NoError(t, err) created, ok := createResp.(*api.Grant) require.True(t, ok, helpers.MustMarshal(t, createResp)) assert.True(t, strings.HasPrefix(created.ID, "asgn_"), created.ID) assert.Equal(t, project.ID, created.ProjectID) - assert.Equal(t, userID, created.PrincipalID) - assert.Equal(t, api.GrantPrincipalTypeUser, created.PrincipalType) assert.Equal(t, api.GrantRelationViewer, created.Relation) assert.Equal(t, api.GrantObjectTypeProject, created.ObjectType) require.True(t, created.User.IsSet()) assert.Equal(t, api.UserID(userID), created.User.Value.UserID) assert.True(t, created.User.Value.Identifier.IsSet()) assert.False(t, created.Team.IsSet()) - assert.False(t, created.Principal.IsSet()) + assert.False(t, created.User.Value.Schema.IsSet()) getResp, err := client.GetGrant(t.Context(), api.GetGrantParams{ ID: created.ID, @@ -90,21 +120,15 @@ func TestGrantCreateGetRevoke(t *testing.T) { }) require.NoError(t, err) - createResp, err := client.CreateGrant(t.Context(), &api.CreateGrantRequest{ - PrincipalType: api.CreateGrantRequestPrincipalTypeTeam, - PrincipalID: team.ID, - Relation: api.CreateGrantRequestRelationEditor, - }, params()) + createResp, err := client.CreateGrant(t.Context(), teamIDGrant(team.ID, api.CreateGrantRequestRelationEditor), params()) require.NoError(t, err) created, ok := createResp.(*api.Grant) require.True(t, ok, helpers.MustMarshal(t, createResp)) - assert.Equal(t, api.GrantPrincipalTypeTeam, created.PrincipalType) assert.Equal(t, api.GrantRelationEditor, created.Relation) require.True(t, created.Team.IsSet()) assert.Equal(t, team.ID, created.Team.Value.TeamID) assert.Equal(t, team.Name, created.Team.Value.Name.Or("")) assert.False(t, created.User.IsSet()) - assert.False(t, created.Principal.IsSet()) delResp, err := client.DeleteGrant(t.Context(), api.DeleteGrantParams{ ID: created.ID, @@ -118,11 +142,7 @@ func TestGrantCreateGetRevoke(t *testing.T) { t.Parallel() userID := harness.CreateUserWithTeam(t, platform.ID) - req := &api.CreateGrantRequest{ - PrincipalType: api.CreateGrantRequestPrincipalTypeUser, - PrincipalID: userID, - Relation: api.CreateGrantRequestRelationAdmin, - } + req := userIDGrant(userID, api.CreateGrantRequestRelationAdmin) first, err := client.CreateGrant(t.Context(), req, params()) require.NoError(t, err) require.IsType(t, &api.Grant{}, first, helpers.MustMarshal(t, first)) @@ -142,11 +162,7 @@ func TestGrantCreateGetRevoke(t *testing.T) { harness.SetProjectSecretOnApiClient(t, foreign, other) userID := harness.CreateUserWithTeam(t, platform.ID) - resp, err := foreign.CreateGrant(t.Context(), &api.CreateGrantRequest{ - PrincipalType: api.CreateGrantRequestPrincipalTypeUser, - PrincipalID: userID, - Relation: api.CreateGrantRequestRelationViewer, - }, params()) + resp, err := foreign.CreateGrant(t.Context(), userIDGrant(userID, api.CreateGrantRequestRelationViewer), params()) require.NoError(t, err) assertGrantNotFound(t, resp) }) @@ -155,11 +171,7 @@ func TestGrantCreateGetRevoke(t *testing.T) { t.Parallel() userID := harness.CreateUserWithTeam(t, platform.ID) - req := &api.CreateGrantRequest{ - PrincipalType: api.CreateGrantRequestPrincipalTypeUser, - PrincipalID: userID, - Relation: api.CreateGrantRequestRelationViewer, - } + req := userIDGrant(userID, api.CreateGrantRequestRelationViewer) first, err := client.CreateGrant(t.Context(), req, params()) require.NoError(t, err) created, ok := first.(*api.Grant) @@ -243,6 +255,78 @@ func TestGrantCreateGetRevoke(t *testing.T) { }) } +func TestGrantCreateLocators(t *testing.T) { + t.Parallel() + + platform := harness.EnsurePlatformProject(t) + project, err := harness.EnsureProjectService(t).Create(t.Context(), helpers.ProjectName(), nil, true) + require.NoError(t, err) + + client, err := helpers.NewApiClient(harness.EnsureTestServer(t).URL) + require.NoError(t, err) + harness.SetProjectSecretOnApiClient(t, client, project) + + platformClient, err := helpers.NewApiClient(harness.EnsureTestServer(t).URL) + require.NoError(t, err) + harness.SetProjectSecretOnApiClient(t, platformClient, platform) + + params := api.CreateGrantParams{ProjectID: api.ProjectID(project.ID)} + + t.Run("create by identifier", func(t *testing.T) { + t.Parallel() + userID := harness.CreateUserWithTeam(t, platform.ID) + userResp, err := platformClient.GetUserByID(t.Context(), api.GetUserByIDParams{UserID: api.UserID(userID)}) + require.NoError(t, err) + user, ok := userResp.(*api.User) + require.True(t, ok, helpers.MustMarshal(t, userResp)) + require.True(t, user.Identifier.IsSet()) + + createResp, err := client.CreateGrant(t.Context(), userIdentifierGrant(strings.ToUpper(user.Identifier.Value), api.CreateGrantRequestRelationViewer), params) + require.NoError(t, err) + created, ok := createResp.(*api.Grant) + require.True(t, ok, helpers.MustMarshal(t, createResp)) + require.True(t, created.User.IsSet()) + assert.Equal(t, api.UserID(userID), created.User.Value.UserID) + }) + + t.Run("create by team name", func(t *testing.T) { + t.Parallel() + team, err := harness.EnsureTeamService(t).Create(t.Context(), service.CreateTeamInput{ + ProjectID: platform.ID, + Name: helpers.TeamName(), + }) + require.NoError(t, err) + + createResp, err := client.CreateGrant(t.Context(), teamNameGrant(strings.ToUpper(team.Name), api.CreateGrantRequestRelationAdmin), params) + require.NoError(t, err) + created, ok := createResp.(*api.Grant) + require.True(t, ok, helpers.MustMarshal(t, createResp)) + require.True(t, created.Team.IsSet()) + assert.Equal(t, team.ID, created.Team.Value.TeamID) + assert.Equal(t, team.Name, created.Team.Value.Name.Or("")) + }) + + t.Run("unknown identifier is principal not found", func(t *testing.T) { + t.Parallel() + resp, err := client.CreateGrant(t.Context(), userIdentifierGrant("nobody@example.com", api.CreateGrantRequestRelationViewer), params) + require.NoError(t, err) + assertGrantPrincipalNotFound(t, resp) + }) + + t.Run("both user and team is invalid", func(t *testing.T) { + t.Parallel() + resp, err := client.CreateGrant(t.Context(), &api.CreateGrantRequest{ + Relation: api.CreateGrantRequestRelationViewer, + User: api.NewOptUserLocator(api.UserLocator{UserID: api.NewOptUserID("user_1")}), + Team: api.NewOptTeamLocator(api.TeamLocator{TeamID: api.NewOptTeamID("team_1")}), + }, params) + require.NoError(t, err) + bad, ok := resp.(*api.CreateGrantBadRequest) + require.True(t, ok, helpers.MustMarshal(t, resp)) + assert.Equal(t, api.ErrorCode("grant.invalid"), bad.Code) + }) +} + func TestGrantQuery(t *testing.T) { t.Parallel() @@ -260,11 +344,7 @@ func TestGrantQuery(t *testing.T) { } userID := harness.CreateUserWithTeam(t, platform.ID) - userGrantResp, err := client.CreateGrant(t.Context(), &api.CreateGrantRequest{ - PrincipalType: api.CreateGrantRequestPrincipalTypeUser, - PrincipalID: userID, - Relation: api.CreateGrantRequestRelationViewer, - }, api.CreateGrantParams{ProjectID: api.ProjectID(project.ID)}) + userGrantResp, err := client.CreateGrant(t.Context(), userIDGrant(userID, api.CreateGrantRequestRelationViewer), api.CreateGrantParams{ProjectID: api.ProjectID(project.ID)}) require.NoError(t, err) userGrant, ok := userGrantResp.(*api.Grant) require.True(t, ok, helpers.MustMarshal(t, userGrantResp)) @@ -274,21 +354,13 @@ func TestGrantQuery(t *testing.T) { Name: helpers.TeamName(), }) require.NoError(t, err) - teamGrantResp, err := client.CreateGrant(t.Context(), &api.CreateGrantRequest{ - PrincipalType: api.CreateGrantRequestPrincipalTypeTeam, - PrincipalID: team.ID, - Relation: api.CreateGrantRequestRelationEditor, - }, api.CreateGrantParams{ProjectID: api.ProjectID(project.ID)}) + teamGrantResp, err := client.CreateGrant(t.Context(), teamIDGrant(team.ID, api.CreateGrantRequestRelationEditor), api.CreateGrantParams{ProjectID: api.ProjectID(project.ID)}) require.NoError(t, err) teamGrant, ok := teamGrantResp.(*api.Grant) require.True(t, ok, helpers.MustMarshal(t, teamGrantResp)) revokedUserID := harness.CreateUserWithTeam(t, platform.ID) - revokedResp, err := client.CreateGrant(t.Context(), &api.CreateGrantRequest{ - PrincipalType: api.CreateGrantRequestPrincipalTypeUser, - PrincipalID: revokedUserID, - Relation: api.CreateGrantRequestRelationAdmin, - }, api.CreateGrantParams{ProjectID: api.ProjectID(project.ID)}) + revokedResp, err := client.CreateGrant(t.Context(), userIDGrant(revokedUserID, api.CreateGrantRequestRelationAdmin), api.CreateGrantParams{ProjectID: api.ProjectID(project.ID)}) require.NoError(t, err) revokedGrant, ok := revokedResp.(*api.Grant) require.True(t, ok, helpers.MustMarshal(t, revokedResp)) @@ -356,24 +428,23 @@ func TestGrantQuery(t *testing.T) { assert.True(t, listedUser.User.Value.Identifier.IsSet()) assert.Equal(t, "email", listedUser.User.Value.IdentifierProperty.Or("")) assert.False(t, listedUser.Team.IsSet()) - assert.False(t, listedUser.Principal.IsSet()) + assert.False(t, listedUser.User.Value.Schema.IsSet()) listedTeam := got[teamGrant.ID] require.True(t, listedTeam.Team.IsSet()) assert.Equal(t, team.ID, listedTeam.Team.Value.TeamID) assert.Equal(t, team.Name, listedTeam.Team.Value.Name.Or("")) assert.False(t, listedTeam.User.IsSet()) - assert.False(t, listedTeam.Principal.IsSet()) + assert.False(t, listedTeam.Team.Value.Status.IsSet()) getUser, err := client.GetGrant(t.Context(), getParams(userGrant.ID)) require.NoError(t, err) gotUser, ok := getUser.(*api.Grant) require.True(t, ok, helpers.MustMarshal(t, getUser)) assert.Equal(t, listedUser.ID, gotUser.ID) - assert.Equal(t, listedUser.PrincipalID, gotUser.PrincipalID) assert.Equal(t, listedUser.User, gotUser.User) assert.Equal(t, listedUser.Team, gotUser.Team) - assert.False(t, gotUser.Principal.IsSet()) + assert.False(t, gotUser.User.Value.Schema.IsSet()) getTeam, err := client.GetGrant(t.Context(), getParams(teamGrant.ID)) require.NoError(t, err) @@ -391,14 +462,25 @@ func TestGrantQuery(t *testing.T) { usersOnly := queryGrants(t, &api.QueryGrantsRequest{ Filter: []api.QueryGrantsRequestFilterItem{{ - Field: api.GrantFilterFieldPrincipalType, + Field: api.GrantFilterFieldUserID, Operation: api.FilterOperationEquals, - Value: api.NewOptFilterValue(api.NewStringFilterValue("user")), + Value: api.NewOptFilterValue(api.NewStringFilterValue(userID)), }}, }) - for _, g := range usersOnly.Grants { - assert.Equal(t, api.GrantPrincipalTypeUser, g.PrincipalType) - } + require.Len(t, usersOnly.Grants, 1) + require.True(t, usersOnly.Grants[0].User.IsSet()) + assert.Equal(t, api.UserID(userID), usersOnly.Grants[0].User.Value.UserID) + + teamsOnly := queryGrants(t, &api.QueryGrantsRequest{ + Filter: []api.QueryGrantsRequestFilterItem{{ + Field: api.GrantFilterFieldTeamID, + Operation: api.FilterOperationEquals, + Value: api.NewOptFilterValue(api.NewStringFilterValue(team.ID)), + }}, + }) + require.Len(t, teamsOnly.Grants, 1) + require.True(t, teamsOnly.Grants[0].Team.IsSet()) + assert.Equal(t, team.ID, teamsOnly.Grants[0].Team.Value.TeamID) t.Run("401 without token", func(t *testing.T) { anon, err := helpers.NewApiClient(harness.EnsureTestServer(t).URL) @@ -439,11 +521,7 @@ func TestGrantQueryExpand(t *testing.T) { expandPrincipal := []api.GrantExpand{api.GrantExpandPrincipal} userID := harness.CreateUserWithTeam(t, platform.ID) - userGrantResp, err := client.CreateGrant(t.Context(), &api.CreateGrantRequest{ - PrincipalType: api.CreateGrantRequestPrincipalTypeUser, - PrincipalID: userID, - Relation: api.CreateGrantRequestRelationViewer, - }, createParams) + userGrantResp, err := client.CreateGrant(t.Context(), userIDGrant(userID, api.CreateGrantRequestRelationViewer), createParams) require.NoError(t, err) userGrant, ok := userGrantResp.(*api.Grant) require.True(t, ok, helpers.MustMarshal(t, userGrantResp)) @@ -453,21 +531,13 @@ func TestGrantQueryExpand(t *testing.T) { Name: helpers.TeamName(), }) require.NoError(t, err) - teamGrantResp, err := client.CreateGrant(t.Context(), &api.CreateGrantRequest{ - PrincipalType: api.CreateGrantRequestPrincipalTypeTeam, - PrincipalID: team.ID, - Relation: api.CreateGrantRequestRelationEditor, - }, createParams) + teamGrantResp, err := client.CreateGrant(t.Context(), teamIDGrant(team.ID, api.CreateGrantRequestRelationEditor), createParams) require.NoError(t, err) teamGrant, ok := teamGrantResp.(*api.Grant) require.True(t, ok, helpers.MustMarshal(t, teamGrantResp)) deletedUserID := harness.CreateUserWithTeam(t, platform.ID) - deletedGrantResp, err := client.CreateGrant(t.Context(), &api.CreateGrantRequest{ - PrincipalType: api.CreateGrantRequestPrincipalTypeUser, - PrincipalID: deletedUserID, - Relation: api.CreateGrantRequestRelationAdmin, - }, createParams) + deletedGrantResp, err := client.CreateGrant(t.Context(), userIDGrant(deletedUserID, api.CreateGrantRequestRelationAdmin), createParams) require.NoError(t, err) deletedGrant, ok := deletedGrantResp.(*api.Grant) require.True(t, ok, helpers.MustMarshal(t, deletedGrantResp)) @@ -498,33 +568,31 @@ func TestGrantQueryExpand(t *testing.T) { listedUser := got[userGrant.ID] require.True(t, listedUser.User.IsSet()) - require.True(t, listedUser.Principal.IsSet()) - require.False(t, listedUser.Principal.IsNull()) - expandedUser, ok := listedUser.Principal.Value.GetUser() - require.True(t, ok, "user grant principal must be the User body") + require.True(t, listedUser.User.Value.Schema.IsSet()) + require.True(t, listedUser.User.Value.Metadata.IsSet()) getUser, err := platformClient.GetUserByID(t.Context(), api.GetUserByIDParams{UserID: api.UserID(userID)}) require.NoError(t, err) wantUser, ok := getUser.(*api.User) require.True(t, ok, helpers.MustMarshal(t, getUser)) - assert.Equal(t, *wantUser, expandedUser) + assert.Equal(t, wantUser.Schema, listedUser.User.Value.Schema.Value) + assert.Equal(t, wantUser.Attributes, listedUser.User.Value.Attributes.Value) + assert.Equal(t, wantUser.Metadata.Status, listedUser.User.Value.Metadata.Value.Status) listedTeam := got[teamGrant.ID] require.True(t, listedTeam.Team.IsSet()) - require.True(t, listedTeam.Principal.IsSet()) - require.False(t, listedTeam.Principal.IsNull()) - expandedTeam, ok := listedTeam.Principal.Value.GetTeamResponse() - require.True(t, ok, "team grant principal must be the Team body") + require.True(t, listedTeam.Team.Value.Status.IsSet()) getTeam, err := platformClient.GetTeam(t.Context(), api.GetTeamParams{TeamID: api.TeamID(team.ID)}) require.NoError(t, err) wantTeam, ok := getTeam.(*api.TeamResponse) require.True(t, ok, helpers.MustMarshal(t, getTeam)) - assert.Equal(t, *wantTeam, expandedTeam) + assert.Equal(t, wantTeam.Status, listedTeam.Team.Value.Status.Value) + assert.Equal(t, wantTeam.CreatedAt, listedTeam.Team.Value.CreatedAt.Value) + assert.Equal(t, wantTeam.UpdatedAt, listedTeam.Team.Value.UpdatedAt.Value) listedDeleted := got[deletedGrant.ID] require.True(t, listedDeleted.User.IsSet()) assert.Equal(t, api.UserID(deletedUserID), listedDeleted.User.Value.UserID) - require.True(t, listedDeleted.Principal.IsSet()) - assert.True(t, listedDeleted.Principal.IsNull()) + assert.False(t, listedDeleted.User.Value.Schema.IsSet()) withoutExpand := queryGrants(t, &api.QueryGrantsRequest{Limit: api.NewOptLimit(1)}) withExpand := queryGrants(t, &api.QueryGrantsRequest{ @@ -546,8 +614,8 @@ func TestGrantQueryExpand(t *testing.T) { require.Len(t, followWithout.Grants, 1) require.Len(t, followWith.Grants, 1) assert.Equal(t, followWithout.Grants[0].ID, followWith.Grants[0].ID) - assert.False(t, followWithout.Grants[0].Principal.IsSet()) - assert.True(t, followWith.Grants[0].Principal.IsSet()) + assert.False(t, followWithout.Grants[0].User.Value.Schema.IsSet() || followWithout.Grants[0].Team.Value.Status.IsSet()) + assert.True(t, followWith.Grants[0].User.Value.Schema.IsSet() || followWith.Grants[0].Team.Value.Status.IsSet()) t.Run("unknown expand is 400", func(t *testing.T) { body := `{"expand":["nope"]}` @@ -577,6 +645,20 @@ func TestGrantQueryExpand(t *testing.T) { require.IsType(t, &api.QueryGrantsForbidden{}, resp, helpers.MustMarshal(t, resp)) assert.Equal(t, api.ErrorCode("grant.permission_denied"), resp.(*api.QueryGrantsForbidden).Code) }) + + t.Run("GET expand inlines extras", func(t *testing.T) { + getResp, err := client.GetGrant(t.Context(), api.GetGrantParams{ + ID: userGrant.ID, + ProjectID: api.ProjectID(project.ID), + Expand: expandPrincipal, + }) + require.NoError(t, err) + got, ok := getResp.(*api.Grant) + require.True(t, ok, helpers.MustMarshal(t, getResp)) + require.True(t, got.User.IsSet()) + assert.True(t, got.User.Value.Schema.IsSet()) + assert.True(t, got.User.Value.Metadata.IsSet()) + }) } func assertGrantNotFound(t *testing.T, resp any) { @@ -593,6 +675,13 @@ func assertGrantNotFound(t *testing.T, resp any) { } } +func assertGrantPrincipalNotFound(t *testing.T, resp any) { + t.Helper() + nf, ok := resp.(*api.CreateGrantNotFound) + require.True(t, ok, helpers.MustMarshal(t, resp)) + assert.Equal(t, api.ErrorCode("grant.principal_not_found"), nf.Code) +} + func assertGrantAlreadyExists(t *testing.T, resp any) { t.Helper() conflict, ok := resp.(*api.CreateGrantConflict) diff --git a/internal/service/grant.go b/internal/service/grant.go index 81803033e..ab8a293bb 100644 --- a/internal/service/grant.go +++ b/internal/service/grant.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "log/slog" "maps" + "strings" "time" "github.com/zitadel/nextgen/internal/audit" @@ -56,11 +58,13 @@ func NewGrantService(v2Pool *DB, refs UserRefResolver, platformProjectID string) } type CreateGrantInput struct { - ProjectID string - PrincipalType domain.AuthzPrincipalType - PrincipalID string - Relation string - ExpiresAt *time.Time + ProjectID string + Relation string + ExpiresAt *time.Time + UserID string + Identifier string + TeamID string + TeamName string } func (s *GrantService) Create(ctx context.Context, input CreateGrantInput) (*Grant, error) { @@ -70,19 +74,16 @@ func (s *GrantService) Create(ctx context.Context, input CreateGrantInput) (*Gra var created *domain.AuthzAssignment err := s.v2Pool.Transaction(ctx, func(ctx context.Context, tx Statementer[AllStatements]) error { - home, err := s.resolvePrincipalHome(ctx, tx.Statements(), input.PrincipalType, input.PrincipalID) + principalType, principalID, err := s.resolveLocator(ctx, tx.Statements(), input) if err != nil { return err } - if err := s.loadPrincipal(ctx, tx.Statements(), home, input.PrincipalType, input.PrincipalID); err != nil { - return err - } asgn := &domain.AuthzAssignment{ ProjectID: input.ProjectID, CatalogID: domain.SystemCatalogID, - PrincipalType: input.PrincipalType, - PrincipalID: input.PrincipalID, + PrincipalType: principalType, + PrincipalID: principalID, ObjectType: "project", Relation: input.Relation, ExpiresAt: input.ExpiresAt, @@ -109,17 +110,17 @@ func (s *GrantService) Create(ctx context.Context, input CreateGrantInput) (*Gra } return nil, domain.ErrInternal(err).WithMessage("failed to create grant") } - grant, err := s.hydrateOne(ctx, created) + grant, err := s.hydrate(ctx, false, created) if err != nil { // The assignment has already committed: a ref/team load failure must // not fail the create — the caller would retry and hit unique // constraints — so the response carries id-only refs (ADR 058). return idOnlyGrant(created), nil } - return grant, nil + return grant[0], nil } -func (s *GrantService) Get(ctx context.Context, projectID, id string) (*Grant, error) { +func (s *GrantService) Get(ctx context.Context, projectID, id string, includePrincipal bool) (*Grant, error) { asgn, err := s.v2Pool.Statements().GetAuthzAssignment(ctx, projectID, id) if err != nil { if _, ok := errors.AsType[*database.NoRowFoundError](err); ok { @@ -130,7 +131,11 @@ func (s *GrantService) Get(ctx context.Context, projectID, id string) (*Grant, e if asgn.RevokedAt != nil || !isManagedGrant(asgn) { return nil, domain.ErrGrantNotFound() } - return s.hydrateOne(ctx, asgn) + grants, err := s.hydrate(ctx, includePrincipal, asgn) + if err != nil { + return nil, err + } + return grants[0], nil } func (s *GrantService) Revoke(ctx context.Context, projectID, id string) error { @@ -163,17 +168,31 @@ func (s *GrantService) Revoke(ctx context.Context, projectID, id string) error { } func validateCreateGrant(input CreateGrantInput) error { - switch input.PrincipalType { - case domain.AuthzPrincipalTypeUser: - if !domain.PrefixUser.Matches(input.PrincipalID) { - return domain.ErrGrantInvalid().WithDetails("principal_id must use the user_ prefix") - } - case domain.AuthzPrincipalTypeTeam: - if !domain.PrefixTeam.Matches(input.PrincipalID) { - return domain.ErrGrantInvalid().WithDetails("principal_id must use the team_ prefix") - } - default: - return domain.ErrGrantInvalid().WithDetails("principal_type must be user or team") + userID := strings.TrimSpace(input.UserID) + identifier := strings.TrimSpace(input.Identifier) + teamID := strings.TrimSpace(input.TeamID) + teamName := strings.TrimSpace(input.TeamName) + n := 0 + if userID != "" { + n++ + } + if identifier != "" { + n++ + } + if teamID != "" { + n++ + } + if teamName != "" { + n++ + } + if n != 1 { + return domain.ErrGrantInvalid().WithDetails("exactly one of user.user_id, user.identifier, team.team_id, or team.name is required") + } + if userID != "" && !domain.PrefixUser.Matches(userID) { + return domain.ErrGrantInvalid().WithDetails("user_id must use the user_ prefix") + } + if teamID != "" && !domain.PrefixTeam.Matches(teamID) { + return domain.ErrGrantInvalid().WithDetails("team_id must use the team_ prefix") } if _, ok := allowedGrantRelations[input.Relation]; !ok { return domain.ErrGrantInvalid().WithDetails("relation must be viewer, editor, or admin") @@ -184,6 +203,158 @@ func validateCreateGrant(input CreateGrantInput) error { return nil } +func (s *GrantService) locatorHome(grantProjectID string) string { + if s.platformProjectID != "" { + return s.platformProjectID + } + return grantProjectID +} + +func (s *GrantService) resolveLocator(ctx context.Context, stmts AllStatements, input CreateGrantInput) (domain.AuthzPrincipalType, string, error) { + switch { + case strings.TrimSpace(input.UserID) != "": + userID := strings.TrimSpace(input.UserID) + home, err := s.resolvePrincipalHome(ctx, stmts, domain.AuthzPrincipalTypeUser, userID) + if err != nil { + return "", "", err + } + if err := s.loadPrincipal(ctx, stmts, home, domain.AuthzPrincipalTypeUser, userID); err != nil { + return "", "", err + } + return domain.AuthzPrincipalTypeUser, userID, nil + case strings.TrimSpace(input.Identifier) != "": + id, err := s.resolveUserByIdentifier(ctx, stmts, s.locatorHome(input.ProjectID), strings.TrimSpace(input.Identifier)) + if err != nil { + return "", "", err + } + return domain.AuthzPrincipalTypeUser, id, nil + case strings.TrimSpace(input.TeamID) != "": + teamID := strings.TrimSpace(input.TeamID) + home, err := s.resolvePrincipalHome(ctx, stmts, domain.AuthzPrincipalTypeTeam, teamID) + if err != nil { + return "", "", err + } + if err := s.loadPrincipal(ctx, stmts, home, domain.AuthzPrincipalTypeTeam, teamID); err != nil { + return "", "", err + } + return domain.AuthzPrincipalTypeTeam, teamID, nil + case strings.TrimSpace(input.TeamName) != "": + id, err := s.resolveTeamByName(ctx, stmts, s.locatorHome(input.ProjectID), strings.TrimSpace(input.TeamName)) + if err != nil { + return "", "", err + } + return domain.AuthzPrincipalTypeTeam, id, nil + default: + return "", "", domain.ErrGrantInvalid().WithDetails("exactly one of user.user_id, user.identifier, team.team_id, or team.name is required") + } +} + +func (s *GrantService) resolveUserByIdentifier(ctx context.Context, stmts AllStatements, home, identifier string) (string, error) { + keys, err := s.designatedIdentifierKeys(ctx, stmts, home) + if err != nil { + return "", err + } + found := map[string]struct{}{} + for _, key := range keys { + user, err := stmts.GetUser(ctx, database.And( + database.Equal(database.Col(domain.UserFieldProjectID), home), + database.Equal(database.Col(domain.UserFieldStatus), domain.UserStatusActive.String()), + ), UserQueryOptions{ + Attributes: []domain.Attribute{{Key: domain.AttributeKey(key), Value: identifier}}, + UniqueAttributesOnly: true, + }) + if err != nil { + if _, ok := errors.AsType[*database.NoRowFoundError](err); ok { + continue + } + if _, ok := errors.AsType[*database.MultipleRowsFoundError](err); ok { + getLoggingContext(ctx, "grant").Info("grant identifier lookup is ambiguous", + slog.String("home_project_id", home), + slog.String("identifier_property", key), + ) + return "", domain.ErrGrantPrincipalNotFound() + } + return "", err + } + found[user.ID] = struct{}{} + } + if len(found) != 1 { + if len(found) > 1 { + getLoggingContext(ctx, "grant").Info("grant identifier lookup matched multiple users", + slog.String("home_project_id", home), + slog.Int("matches", len(found)), + ) + } + return "", domain.ErrGrantPrincipalNotFound() + } + for id := range found { + return id, nil + } + return "", domain.ErrGrantPrincipalNotFound() +} + +func (s *GrantService) designatedIdentifierKeys(ctx context.Context, stmts AllStatements, projectID string) ([]string, error) { + ctx = WithAuthzListUnrestricted(ctx) + list := func(cursor []byte) (*database.ListResult[*domain.JSONSchema], error) { + return stmts.ListJSONSchemas(ctx, &database.ListOptions[domain.JSONSchemaField]{ + Filter: database.And( + database.Equal(database.Col(domain.JSONSchemaFieldProjectID), projectID), + database.Equal(database.Col(domain.JSONSchemaFieldKind), domain.JSONSchemaKindUserSchema.String()), + ), + Pagination: database.Page[domain.JSONSchemaField]{ + Limit: refSchemaPageSize, + Cursor: cursor, + OrderBy: database.OrderBy[domain.JSONSchemaField]{ + Columns: []database.Column[domain.JSONSchemaField]{database.Col(domain.JSONSchemaFieldURL)}, + Direction: database.OrderAsc, + }, + }, + }, JSONSchemaQueryOptions{}) + } + first, err := list(nil) + if err != nil { + return nil, err + } + var keys []string + seen := map[string]struct{}{} + for schema, err := range first.Iterate(list) { + if err != nil { + return nil, err + } + key := domain.DesignatedIdentifier(schema.Schema) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + keys = append(keys, key) + } + return keys, nil +} + +func (s *GrantService) resolveTeamByName(ctx context.Context, stmts AllStatements, home, name string) (string, error) { + team, err := stmts.GetTeam(ctx, database.And( + database.Equal(database.Col(domain.TeamFieldProjectID), home), + database.StringEqualFold(database.Col(domain.TeamFieldName), name), + database.Equal(database.Col(domain.TeamFieldStatus), domain.TeamStatusActive.String()), + )) + if err != nil { + if _, ok := errors.AsType[*database.NoRowFoundError](err); ok { + return "", domain.ErrGrantPrincipalNotFound() + } + if _, ok := errors.AsType[*database.MultipleRowsFoundError](err); ok { + getLoggingContext(ctx, "grant").Info("grant team name lookup is ambiguous", + slog.String("home_project_id", home), + ) + return "", domain.ErrGrantPrincipalNotFound() + } + return "", err + } + return team.ID, nil +} + func (s *GrantService) resolvePrincipalHome(ctx context.Context, stmts AllStatements, principalType domain.AuthzPrincipalType, principalID string) (string, error) { scope, err := stmts.GetResourceScope(ctx, principalID) if err != nil { @@ -242,12 +413,12 @@ func (s *GrantService) loadPrincipal(ctx context.Context, stmts AllStatements, h } const ( - grantFieldCreatedAt = "created_at" - grantFieldPrincipalType = "principal_type" - grantFieldPrincipalID = "principal_id" - grantFieldRelation = "relation" - grantFieldExpiresAt = "expires_at" - grantFieldID = "id" + grantFieldCreatedAt = "created_at" + grantFieldUserID = "user_id" + grantFieldTeamID = "team_id" + grantFieldRelation = "relation" + grantFieldExpiresAt = "expires_at" + grantFieldID = "id" ) // GrantPrincipal is the GET-user or GET-team body for expand: ["principal"]. @@ -258,8 +429,9 @@ type GrantPrincipal struct { } // Grant is an assignment plus the resolved principal label for the HTTP API. -// Principal is nil unless expand was requested; a non-nil Principal with both -// User and Team nil is the ADR 059 missing-principal case (wire null). +// Principal is nil unless expand was requested. A non-nil Principal with both +// User and Team nil is the missing-principal case: HTTP still emits the +// degraded ref (`user_id` / `team_id` only). type Grant struct { Assignment *domain.AuthzAssignment User *domain.UserRef @@ -367,14 +539,6 @@ func (s *GrantService) List(ctx context.Context, req ListGrantsRequest) (*ListGr }, nil } -func (s *GrantService) hydrateOne(ctx context.Context, asgn *domain.AuthzAssignment) (*Grant, error) { - grants, err := s.hydrate(ctx, false, asgn) - if err != nil { - return nil, err - } - return grants[0], nil -} - func idOnlyGrant(asgn *domain.AuthzAssignment) *Grant { g := &Grant{Assignment: asgn} switch asgn.PrincipalType { @@ -590,23 +754,10 @@ func grantFilter(f Filter) (database.Filter[domain.AuthzAssignmentField], error) return createdAtFilter(f.Operation, database.Col(domain.AuthzAssignmentFieldCreatedAt), f.Value) case grantFieldExpiresAt: return expiresAtFilter(f) - case grantFieldPrincipalType: - value, err := stringFilterValue(f) - if err != nil { - return nil, err - } - switch value { - case domain.AuthzPrincipalTypeUser.String(), domain.AuthzPrincipalTypeTeam.String(): - default: - return nil, domain.ErrRequestInvalid().WithDetails(fmt.Sprintf("unknown principal_type %q", value)) - } - return stringEqualsFilter(f.Operation, database.Col(domain.AuthzAssignmentFieldPrincipalType), value) - case grantFieldPrincipalID: - value, err := stringFilterValue(f) - if err != nil { - return nil, err - } - return stringFilter(f.Operation, database.Col(domain.AuthzAssignmentFieldPrincipalID), value) + case grantFieldUserID: + return principalIDFilter(f, domain.AuthzPrincipalTypeUser) + case grantFieldTeamID: + return principalIDFilter(f, domain.AuthzPrincipalTypeTeam) case grantFieldRelation: value, err := stringFilterValue(f) if err != nil { @@ -621,6 +772,22 @@ func grantFilter(f Filter) (database.Filter[domain.AuthzAssignmentField], error) } } +func principalIDFilter(f Filter, principalType domain.AuthzPrincipalType) (database.Filter[domain.AuthzAssignmentField], error) { + value, err := stringFilterValue(f) + if err != nil { + return nil, err + } + typeFilter, err := stringEqualsFilter(filterOpEquals, database.Col(domain.AuthzAssignmentFieldPrincipalType), principalType.String()) + if err != nil { + return nil, err + } + idFilter, err := stringEqualsFilter(f.Operation, database.Col(domain.AuthzAssignmentFieldPrincipalID), value) + if err != nil { + return nil, err + } + return database.And(typeFilter, idFilter), nil +} + func expiresAtFilter(f Filter) (database.Filter[domain.AuthzAssignmentField], error) { if f.Value == nil { switch f.Operation { diff --git a/internal/service/grant_test.go b/internal/service/grant_test.go index 1fd7d1133..8e2709f81 100644 --- a/internal/service/grant_test.go +++ b/internal/service/grant_test.go @@ -35,10 +35,9 @@ func TestGrantService_Create(t *testing.T) { { name: "ok user grant", input: service.CreateGrantInput{ - ProjectID: "proj_customer", - PrincipalType: domain.AuthzPrincipalTypeUser, - PrincipalID: userID, - Relation: "viewer", + ProjectID: "proj_customer", + UserID: userID, + Relation: "viewer", }, setupStmt: func(s *servicemocks.MockAllStatements) { expectActiveUserPrincipal(s, userID) @@ -64,11 +63,10 @@ func TestGrantService_Create(t *testing.T) { { name: "ok team grant", input: service.CreateGrantInput{ - ProjectID: "proj_customer", - PrincipalType: domain.AuthzPrincipalTypeTeam, - PrincipalID: teamID, - Relation: "editor", - ExpiresAt: &future, + ProjectID: "proj_customer", + TeamID: teamID, + Relation: "editor", + ExpiresAt: &future, }, setupStmt: func(s *servicemocks.MockAllStatements) { expectActiveTeamPrincipal(s, teamID) @@ -92,30 +90,27 @@ func TestGrantService_Create(t *testing.T) { { name: "reject relation team", input: service.CreateGrantInput{ - ProjectID: "proj_customer", - PrincipalType: domain.AuthzPrincipalTypeUser, - PrincipalID: userID, - Relation: "team", + ProjectID: "proj_customer", + UserID: userID, + Relation: "team", }, wantErr: domain.ErrGrantInvalid(), }, { name: "reject sk_proj principal", input: service.CreateGrantInput{ - ProjectID: "proj_customer", - PrincipalType: domain.AuthzPrincipalTypeSKProj, - PrincipalID: "proj_customer", - Relation: "viewer", + ProjectID: "proj_customer", + UserID: "proj_customer", + Relation: "viewer", }, wantErr: domain.ErrGrantInvalid(), }, { name: "unknown principal", input: service.CreateGrantInput{ - ProjectID: "proj_customer", - PrincipalType: domain.AuthzPrincipalTypeUser, - PrincipalID: userID, - Relation: "viewer", + ProjectID: "proj_customer", + UserID: userID, + Relation: "viewer", }, setupStmt: func(s *servicemocks.MockAllStatements) { s.EXPECT().GetResourceScope(gomock.Any(), userID). @@ -126,10 +121,9 @@ func TestGrantService_Create(t *testing.T) { { name: "inactive user", input: service.CreateGrantInput{ - ProjectID: "proj_customer", - PrincipalType: domain.AuthzPrincipalTypeUser, - PrincipalID: userID, - Relation: "admin", + ProjectID: "proj_customer", + UserID: userID, + Relation: "admin", }, setupStmt: func(s *servicemocks.MockAllStatements) { s.EXPECT().GetResourceScope(gomock.Any(), userID).Return(&domain.ResourceScope{ @@ -145,10 +139,9 @@ func TestGrantService_Create(t *testing.T) { { name: "unique conflict", input: service.CreateGrantInput{ - ProjectID: "proj_customer", - PrincipalType: domain.AuthzPrincipalTypeUser, - PrincipalID: userID, - Relation: "viewer", + ProjectID: "proj_customer", + UserID: userID, + Relation: "viewer", }, setupStmt: func(s *servicemocks.MockAllStatements) { expectActiveUserPrincipal(s, userID) @@ -160,10 +153,9 @@ func TestGrantService_Create(t *testing.T) { { name: "principal id prefix mismatch", input: service.CreateGrantInput{ - ProjectID: "proj_customer", - PrincipalType: domain.AuthzPrincipalTypeUser, - PrincipalID: teamID, - Relation: "viewer", + ProjectID: "proj_customer", + UserID: teamID, + Relation: "viewer", }, wantErr: domain.ErrGrantInvalid(), }, @@ -203,10 +195,9 @@ func TestGrantService_Create(t *testing.T) { s.EXPECT().InsertEvent(gomock.Any(), gomock.Any()).Return(nil) }) got, err := svc.Create(t.Context(), service.CreateGrantInput{ - ProjectID: "proj_customer", - PrincipalType: domain.AuthzPrincipalTypeUser, - PrincipalID: userID, - Relation: "viewer", + ProjectID: "proj_customer", + UserID: userID, + Relation: "viewer", }) require.NoError(t, err) require.NotNil(t, got) @@ -219,6 +210,179 @@ func TestGrantService_Create(t *testing.T) { }) } +func TestGrantService_CreateLocators(t *testing.T) { + t.Parallel() + + userID := "user_grant_ident" + teamID := "team_grant_name" + schemaDoc := `{"x-identifier":"email"}` + + t.Run("identifier locates active user", func(t *testing.T) { + t.Parallel() + svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { + s.EXPECT().ListJSONSchemas(gomock.Any(), gomock.Any(), gomock.Any()).Return(&database.ListResult[*domain.JSONSchema]{ + Items: []*domain.JSONSchema{{ + ProjectID: grantPlatformProjID, + URL: "https://s/human", + Kind: domain.JSONSchemaKindUserSchema, + Schema: []byte(schemaDoc), + }}, + }, nil) + s.EXPECT().GetUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(&domain.User{ + ProjectID: grantPlatformProjID, + ID: userID, + Metadata: domain.UserMetadata{Status: domain.UserStatusActive}, + }, nil) + s.EXPECT().CreateAuthzAssignment(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, a *domain.AuthzAssignment) error { + assert.Equal(t, domain.AuthzPrincipalTypeUser, a.PrincipalType) + assert.Equal(t, userID, a.PrincipalID) + a.ID = "asgn_ident" + a.CreatedAt = time.Now() + a.UpdatedAt = a.CreatedAt + return nil + }) + s.EXPECT().InsertEvent(gomock.Any(), gomock.Any()).Return(nil) + }) + got, err := svc.Create(t.Context(), service.CreateGrantInput{ + ProjectID: "proj_customer", + Identifier: "Alice@Acme.com", + Relation: "viewer", + }) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, userID, got.Assignment.PrincipalID) + }) + + t.Run("identifier miss is not found", func(t *testing.T) { + t.Parallel() + svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { + s.EXPECT().ListJSONSchemas(gomock.Any(), gomock.Any(), gomock.Any()).Return(&database.ListResult[*domain.JSONSchema]{ + Items: []*domain.JSONSchema{{ + ProjectID: grantPlatformProjID, + URL: "https://s/human", + Kind: domain.JSONSchemaKindUserSchema, + Schema: []byte(schemaDoc), + }}, + }, nil) + s.EXPECT().GetUser(gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, database.NewNoRowFoundError(nil)) + }) + got, err := svc.Create(t.Context(), service.CreateGrantInput{ + ProjectID: "proj_customer", + Identifier: "missing@acme.com", + Relation: "viewer", + }) + require.ErrorIs(t, err, domain.ErrGrantPrincipalNotFound()) + assert.Nil(t, got) + }) + + t.Run("ambiguous identifier is not found", func(t *testing.T) { + t.Parallel() + svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { + s.EXPECT().ListJSONSchemas(gomock.Any(), gomock.Any(), gomock.Any()).Return(&database.ListResult[*domain.JSONSchema]{ + Items: []*domain.JSONSchema{ + { + ProjectID: grantPlatformProjID, + URL: "https://s/human", + Kind: domain.JSONSchemaKindUserSchema, + Schema: []byte(`{"x-identifier":"email"}`), + }, + { + ProjectID: grantPlatformProjID, + URL: "https://s/admin", + Kind: domain.JSONSchemaKindUserSchema, + Schema: []byte(`{"x-identifier":"username"}`), + }, + }, + }, nil) + s.EXPECT().GetUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(&domain.User{ + ID: "user_email_match", Metadata: domain.UserMetadata{Status: domain.UserStatusActive}, + }, nil) + s.EXPECT().GetUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(&domain.User{ + ID: "user_username_match", Metadata: domain.UserMetadata{Status: domain.UserStatusActive}, + }, nil) + }) + got, err := svc.Create(t.Context(), service.CreateGrantInput{ + ProjectID: "proj_customer", + Identifier: "alice", + Relation: "viewer", + }) + require.ErrorIs(t, err, domain.ErrGrantPrincipalNotFound()) + assert.Nil(t, got) + }) + + t.Run("team name locates active team", func(t *testing.T) { + t.Parallel() + svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { + s.EXPECT().GetTeam(gomock.Any(), gomock.Any()).Return(&domain.Team{ + ProjectID: grantPlatformProjID, + ID: teamID, + Name: "Acme AI Admins", + Status: domain.TeamStatusActive, + }, nil) + s.EXPECT().CreateAuthzAssignment(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, a *domain.AuthzAssignment) error { + assert.Equal(t, domain.AuthzPrincipalTypeTeam, a.PrincipalType) + assert.Equal(t, teamID, a.PrincipalID) + a.ID = "asgn_name" + a.CreatedAt = time.Now() + a.UpdatedAt = a.CreatedAt + return nil + }) + s.EXPECT().InsertEvent(gomock.Any(), gomock.Any()).Return(nil) + expectHydrateTeam(s, teamID) + }) + got, err := svc.Create(t.Context(), service.CreateGrantInput{ + ProjectID: "proj_customer", + TeamName: "acme ai admins", + Relation: "admin", + }) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, teamID, got.Assignment.PrincipalID) + }) + + t.Run("team name miss is not found", func(t *testing.T) { + t.Parallel() + svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { + s.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + Return(nil, database.NewNoRowFoundError(nil)) + }) + got, err := svc.Create(t.Context(), service.CreateGrantInput{ + ProjectID: "proj_customer", + TeamName: "no such team", + Relation: "admin", + }) + require.ErrorIs(t, err, domain.ErrGrantPrincipalNotFound()) + assert.Nil(t, got) + }) + + t.Run("both user locators is invalid", func(t *testing.T) { + t.Parallel() + svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) {}) + got, err := svc.Create(t.Context(), service.CreateGrantInput{ + ProjectID: "proj_customer", + UserID: userID, + Identifier: "alice@acme.com", + Relation: "viewer", + }) + require.ErrorIs(t, err, domain.ErrGrantInvalid()) + assert.Nil(t, got) + }) + + t.Run("neither locator is invalid", func(t *testing.T) { + t.Parallel() + svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) {}) + got, err := svc.Create(t.Context(), service.CreateGrantInput{ + ProjectID: "proj_customer", + Relation: "viewer", + }) + require.ErrorIs(t, err, domain.ErrGrantInvalid()) + assert.Nil(t, got) + }) +} + func TestGrantService_Get(t *testing.T) { t.Parallel() @@ -228,7 +392,7 @@ func TestGrantService_Get(t *testing.T) { s.EXPECT().GetAuthzAssignment(gomock.Any(), "proj_customer", "asgn_1").Return( testManagedGrant("asgn_1", "user_grant01"), nil) }) - got, err := svc.Get(t.Context(), "proj_customer", "asgn_1") + got, err := svc.Get(t.Context(), "proj_customer", "asgn_1", false) require.NoError(t, err) assert.Equal(t, "asgn_1", got.Assignment.ID) }) @@ -243,7 +407,7 @@ func TestGrantService_Get(t *testing.T) { RevokedAt: &revoked, }, nil) }) - got, err := svc.Get(t.Context(), "proj_customer", "asgn_1") + got, err := svc.Get(t.Context(), "proj_customer", "asgn_1", false) require.ErrorIs(t, err, domain.ErrGrantNotFound()) assert.Nil(t, got) }) @@ -254,7 +418,7 @@ func TestGrantService_Get(t *testing.T) { s.EXPECT().GetAuthzAssignment(gomock.Any(), "proj_customer", "asgn_missing"). Return(nil, database.NewNoRowFoundError(nil)) }) - got, err := svc.Get(t.Context(), "proj_customer", "asgn_missing") + got, err := svc.Get(t.Context(), "proj_customer", "asgn_missing", false) require.ErrorIs(t, err, domain.ErrGrantNotFound()) assert.Nil(t, got) }) @@ -265,7 +429,7 @@ func TestGrantService_Get(t *testing.T) { s.EXPECT().GetAuthzAssignment(gomock.Any(), "proj_customer", "asgn_setup").Return( domain.NewSKProjProjectSetupAssignment("proj_customer"), nil) }) - got, err := svc.Get(t.Context(), "proj_customer", "asgn_setup") + got, err := svc.Get(t.Context(), "proj_customer", "asgn_setup", false) require.ErrorIs(t, err, domain.ErrGrantNotFound()) assert.Nil(t, got) }) @@ -276,7 +440,7 @@ func TestGrantService_Get(t *testing.T) { s.EXPECT().GetAuthzAssignment(gomock.Any(), "proj_customer", "asgn_own").Return( domain.NewClaimTeamAssignment("proj_customer", "team_owner"), nil) }) - got, err := svc.Get(t.Context(), "proj_customer", "asgn_own") + got, err := svc.Get(t.Context(), "proj_customer", "asgn_own", false) require.ErrorIs(t, err, domain.ErrGrantNotFound()) assert.Nil(t, got) }) @@ -289,7 +453,7 @@ func TestGrantService_Get(t *testing.T) { asgn.ExpiresAt = &expired s.EXPECT().GetAuthzAssignment(gomock.Any(), "proj_customer", "asgn_exp").Return(asgn, nil) }) - got, err := svc.Get(t.Context(), "proj_customer", "asgn_exp") + got, err := svc.Get(t.Context(), "proj_customer", "asgn_exp", false) require.NoError(t, err) require.NotNil(t, got) assert.Equal(t, "asgn_exp", got.Assignment.ID) @@ -308,7 +472,7 @@ func TestGrantService_Get(t *testing.T) { Relation: "viewer", }, nil) }) - got, err := svc.Get(t.Context(), "proj_customer", "asgn_user") + got, err := svc.Get(t.Context(), "proj_customer", "asgn_user", false) require.ErrorIs(t, err, domain.ErrGrantNotFound()) assert.Nil(t, got) }) @@ -320,7 +484,7 @@ func TestGrantService_Get(t *testing.T) { svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { s.EXPECT().GetAuthzAssignment(gomock.Any(), "proj_customer", "asgn_app").Return(asgn, nil) }) - got, err := svc.Get(t.Context(), "proj_customer", "asgn_app") + got, err := svc.Get(t.Context(), "proj_customer", "asgn_app", false) require.ErrorIs(t, err, domain.ErrGrantNotFound()) assert.Nil(t, got) }) @@ -334,7 +498,7 @@ func TestGrantService_Get(t *testing.T) { svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { s.EXPECT().GetAuthzAssignment(gomock.Any(), "proj_customer", "asgn_ts").Return(asgn, nil) }) - got, err := svc.Get(t.Context(), "proj_customer", "asgn_ts") + got, err := svc.Get(t.Context(), "proj_customer", "asgn_ts", false) require.ErrorIs(t, err, domain.ErrGrantNotFound()) assert.Nil(t, got) }) @@ -723,7 +887,7 @@ func TestGrantService_List(t *testing.T) { assert.Equal(t, domain.ErrRequestInvalid().Code, err.(domain.Error).Code) }) - t.Run("filters by principal_type", func(t *testing.T) { + t.Run("filters by user_id", func(t *testing.T) { svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { s.EXPECT().ListManagedGrants(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, projectID string, opts *database.ListOptions[domain.AuthzAssignmentField]) (*database.ListResult[*domain.AuthzAssignment], error) { @@ -734,13 +898,34 @@ func TestGrantService_List(t *testing.T) { }) got, err := svc.List(t.Context(), service.ListGrantsRequest{ ProjectID: "proj_customer", - Filters: []service.Filter{{Field: "principal_type", Operation: "equals", Value: "user"}}, + Filters: []service.Filter{{Field: "user_id", Operation: "equals", Value: userID}}, }) require.NoError(t, err) require.Len(t, got.Grants, 1) assert.Equal(t, userAsgn.ID, got.Grants[0].Assignment.ID) }) + t.Run("filters by team_id", func(t *testing.T) { + svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { + s.EXPECT().ListManagedGrants(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, projectID string, opts *database.ListOptions[domain.AuthzAssignmentField]) (*database.ListResult[*domain.AuthzAssignment], error) { + require.Equal(t, "proj_customer", projectID) + require.NotNil(t, opts.Filter) + return &database.ListResult[*domain.AuthzAssignment]{Items: []*domain.AuthzAssignment{teamAsgn}}, nil + }) + s.EXPECT().ListTeams(gomock.Any(), gomock.Any()).Return(&database.ListResult[*domain.Team]{ + Items: []*domain.Team{{ID: teamID, Name: "Platform admins"}}, + }, nil) + }) + got, err := svc.List(t.Context(), service.ListGrantsRequest{ + ProjectID: "proj_customer", + Filters: []service.Filter{{Field: "team_id", Operation: "equals", Value: teamID}}, + }) + require.NoError(t, err) + require.Len(t, got.Grants, 1) + assert.Equal(t, teamAsgn.ID, got.Grants[0].Assignment.ID) + }) + t.Run("filters by relation", func(t *testing.T) { svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { s.EXPECT().ListManagedGrants(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( From f48e924066e1d7b706c4f56df5196e18ab1aae75 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 15:29:16 +0000 Subject: [PATCH 02/14] fix(api): compare grant expand attributes as JSON Grant expand inlines user attributes onto GrantUser, so ogen types them as GrantUserAttributes rather than UserAttributes. Compare the JSON in the expand integration test. Restore contains on user_id / team_id filters to match the previous principal_id filter. Co-authored-by: Silvan --- internal/api/integration_test/grant_test.go | 4 +++- internal/service/grant.go | 2 +- internal/service/grant_test.go | 16 ++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/internal/api/integration_test/grant_test.go b/internal/api/integration_test/grant_test.go index a766c85b7..8ba5f5fbf 100644 --- a/internal/api/integration_test/grant_test.go +++ b/internal/api/integration_test/grant_test.go @@ -575,7 +575,9 @@ func TestGrantQueryExpand(t *testing.T) { wantUser, ok := getUser.(*api.User) require.True(t, ok, helpers.MustMarshal(t, getUser)) assert.Equal(t, wantUser.Schema, listedUser.User.Value.Schema.Value) - assert.Equal(t, wantUser.Attributes, listedUser.User.Value.Attributes.Value) + // Grant expand inlines attributes onto GrantUser, so the generated type + // is GrantUserAttributes rather than UserAttributes; compare the JSON. + assert.JSONEq(t, helpers.MustMarshal(t, wantUser.Attributes), helpers.MustMarshal(t, listedUser.User.Value.Attributes.Value)) assert.Equal(t, wantUser.Metadata.Status, listedUser.User.Value.Metadata.Value.Status) listedTeam := got[teamGrant.ID] diff --git a/internal/service/grant.go b/internal/service/grant.go index ab8a293bb..4355fa4ba 100644 --- a/internal/service/grant.go +++ b/internal/service/grant.go @@ -781,7 +781,7 @@ func principalIDFilter(f Filter, principalType domain.AuthzPrincipalType) (datab if err != nil { return nil, err } - idFilter, err := stringEqualsFilter(f.Operation, database.Col(domain.AuthzAssignmentFieldPrincipalID), value) + idFilter, err := stringFilter(f.Operation, database.Col(domain.AuthzAssignmentFieldPrincipalID), value) if err != nil { return nil, err } diff --git a/internal/service/grant_test.go b/internal/service/grant_test.go index 8e2709f81..0a50acb48 100644 --- a/internal/service/grant_test.go +++ b/internal/service/grant_test.go @@ -905,6 +905,22 @@ func TestGrantService_List(t *testing.T) { assert.Equal(t, userAsgn.ID, got.Grants[0].Assignment.ID) }) + t.Run("user_id accepts contains", func(t *testing.T) { + svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { + s.EXPECT().ListManagedGrants(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, projectID string, opts *database.ListOptions[domain.AuthzAssignmentField]) (*database.ListResult[*domain.AuthzAssignment], error) { + require.NotNil(t, opts.Filter) + return &database.ListResult[*domain.AuthzAssignment]{Items: []*domain.AuthzAssignment{userAsgn}}, nil + }) + }) + got, err := svc.List(t.Context(), service.ListGrantsRequest{ + ProjectID: "proj_customer", + Filters: []service.Filter{{Field: "user_id", Operation: "contains", Value: "user_"}}, + }) + require.NoError(t, err) + require.Len(t, got.Grants, 1) + }) + t.Run("filters by team_id", func(t *testing.T) { svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { s.EXPECT().ListManagedGrants(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( From 9487264e92ff3a0aacf64fa4217054e752deaf17 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 17:35:22 +0000 Subject: [PATCH 03/14] fix(api): scope grant identifier lookup to designating schemas Identifier lookup now ANDs schema_url to the schemas that set x-identifier for that key, so a unique undesignated property on another schema cannot be selected. Co-authored-by: Silvan --- internal/service/grant.go | 27 ++++--- internal/service/grant_test.go | 137 +++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 12 deletions(-) diff --git a/internal/service/grant.go b/internal/service/grant.go index 4355fa4ba..6dff80040 100644 --- a/internal/service/grant.go +++ b/internal/service/grant.go @@ -250,15 +250,19 @@ func (s *GrantService) resolveLocator(ctx context.Context, stmts AllStatements, } func (s *GrantService) resolveUserByIdentifier(ctx context.Context, stmts AllStatements, home, identifier string) (string, error) { - keys, err := s.designatedIdentifierKeys(ctx, stmts, home) + urlsByKey, err := s.designatedIdentifierKeys(ctx, stmts, home) if err != nil { return "", err } found := map[string]struct{}{} - for _, key := range keys { + for key, urls := range urlsByKey { + if len(urls) == 0 { + continue + } user, err := stmts.GetUser(ctx, database.And( database.Equal(database.Col(domain.UserFieldProjectID), home), database.Equal(database.Col(domain.UserFieldStatus), domain.UserStatusActive.String()), + database.Or(equalIDFilters(domain.UserFieldSchemaURL, urls)...), ), UserQueryOptions{ Attributes: []domain.Attribute{{Key: domain.AttributeKey(key), Value: identifier}}, UniqueAttributesOnly: true, @@ -293,7 +297,11 @@ func (s *GrantService) resolveUserByIdentifier(ctx context.Context, stmts AllSta return "", domain.ErrGrantPrincipalNotFound() } -func (s *GrantService) designatedIdentifierKeys(ctx context.Context, stmts AllStatements, projectID string) ([]string, error) { +// designatedIdentifierKeys maps each x-identifier property to the schema URLs +// that designate it. Lookup must be scoped to those schemas so a unique value +// on a property that is not designated (another schema's notification email, +// for example) cannot be selected. +func (s *GrantService) designatedIdentifierKeys(ctx context.Context, stmts AllStatements, projectID string) (map[string][]string, error) { ctx = WithAuthzListUnrestricted(ctx) list := func(cursor []byte) (*database.ListResult[*domain.JSONSchema], error) { return stmts.ListJSONSchemas(ctx, &database.ListOptions[domain.JSONSchemaField]{ @@ -315,23 +323,18 @@ func (s *GrantService) designatedIdentifierKeys(ctx context.Context, stmts AllSt if err != nil { return nil, err } - var keys []string - seen := map[string]struct{}{} + urlsByKey := map[string][]string{} for schema, err := range first.Iterate(list) { if err != nil { return nil, err } key := domain.DesignatedIdentifier(schema.Schema) - if key == "" { - continue - } - if _, ok := seen[key]; ok { + if key == "" || schema.URL == "" { continue } - seen[key] = struct{}{} - keys = append(keys, key) + urlsByKey[key] = append(urlsByKey[key], schema.URL) } - return keys, nil + return urlsByKey, nil } func (s *GrantService) resolveTeamByName(ctx context.Context, stmts AllStatements, home, name string) (string, error) { diff --git a/internal/service/grant_test.go b/internal/service/grant_test.go index 0a50acb48..b8952b85e 100644 --- a/internal/service/grant_test.go +++ b/internal/service/grant_test.go @@ -312,6 +312,110 @@ func TestGrantService_CreateLocators(t *testing.T) { assert.Nil(t, got) }) + t.Run("identifier skips unique value on undesignated schema", func(t *testing.T) { + t.Parallel() + svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { + s.EXPECT().ListJSONSchemas(gomock.Any(), gomock.Any(), gomock.Any()).Return(&database.ListResult[*domain.JSONSchema]{ + Items: []*domain.JSONSchema{ + { + ProjectID: grantPlatformProjID, + URL: "https://s/human", + Kind: domain.JSONSchemaKindUserSchema, + Schema: []byte(schemaDoc), + }, + { + ProjectID: grantPlatformProjID, + URL: "https://s/machine", + Kind: domain.JSONSchemaKindUserSchema, + Schema: []byte(`{"properties":{"email":{"type":"string","x-unique":"project"}}}`), + }, + }, + }, nil) + s.EXPECT().GetUser(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, filter database.Filter[domain.UserField], opts service.UserQueryOptions) (*domain.User, error) { + assert.True(t, filter.Restricts(database.Col(domain.UserFieldSchemaURL))) + assert.Equal(t, []string{"https://s/human"}, schemaURLEquals(t, filter)) + require.Len(t, opts.Attributes, 1) + assert.Equal(t, domain.AttributeKey("email"), opts.Attributes[0].Key) + return &domain.User{ + ProjectID: grantPlatformProjID, + ID: userID, + SchemaURL: "https://s/human", + Metadata: domain.UserMetadata{Status: domain.UserStatusActive}, + }, nil + }) + s.EXPECT().CreateAuthzAssignment(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, a *domain.AuthzAssignment) error { + assert.Equal(t, userID, a.PrincipalID) + a.ID = "asgn_designated" + a.CreatedAt = time.Now() + a.UpdatedAt = a.CreatedAt + return nil + }) + s.EXPECT().InsertEvent(gomock.Any(), gomock.Any()).Return(nil) + }) + got, err := svc.Create(t.Context(), service.CreateGrantInput{ + ProjectID: "proj_customer", + Identifier: "alice@acme.com", + Relation: "viewer", + }) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, userID, got.Assignment.PrincipalID) + }) + + t.Run("identifier matches among schemas that share a designation", func(t *testing.T) { + t.Parallel() + svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { + s.EXPECT().ListJSONSchemas(gomock.Any(), gomock.Any(), gomock.Any()).Return(&database.ListResult[*domain.JSONSchema]{ + Items: []*domain.JSONSchema{ + { + ProjectID: grantPlatformProjID, + URL: "https://s/human", + Kind: domain.JSONSchemaKindUserSchema, + Schema: []byte(schemaDoc), + }, + { + ProjectID: grantPlatformProjID, + URL: "https://s/admin", + Kind: domain.JSONSchemaKindUserSchema, + Schema: []byte(schemaDoc), + }, + }, + }, nil) + s.EXPECT().GetUser(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, filter database.Filter[domain.UserField], opts service.UserQueryOptions) (*domain.User, error) { + assert.True(t, filter.Restricts(database.Col(domain.UserFieldSchemaURL))) + assert.ElementsMatch(t, []string{"https://s/human", "https://s/admin"}, schemaURLEquals(t, filter)) + require.Len(t, opts.Attributes, 1) + assert.Equal(t, domain.AttributeKey("email"), opts.Attributes[0].Key) + return &domain.User{ + ProjectID: grantPlatformProjID, + ID: userID, + SchemaURL: "https://s/human", + Metadata: domain.UserMetadata{Status: domain.UserStatusActive}, + }, nil + }) + s.EXPECT().CreateAuthzAssignment(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, a *domain.AuthzAssignment) error { + assert.Equal(t, userID, a.PrincipalID) + a.ID = "asgn_shared" + a.CreatedAt = time.Now() + a.UpdatedAt = a.CreatedAt + return nil + }) + s.EXPECT().InsertEvent(gomock.Any(), gomock.Any()).Return(nil) + }) + got, err := svc.Create(t.Context(), service.CreateGrantInput{ + ProjectID: "proj_customer", + Identifier: "alice@acme.com", + Relation: "viewer", + }) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, userID, got.Assignment.PrincipalID) + }) + t.Run("team name locates active team", func(t *testing.T) { t.Parallel() svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { @@ -632,6 +736,39 @@ func expectActiveTeamPrincipal(s *servicemocks.MockAllStatements, teamID string) }, nil) } +func schemaURLEquals(t *testing.T, filter database.Filter[domain.UserField]) []string { + t.Helper() + var urls []string + var walk func(database.Filter[domain.UserField]) + walk = func(f database.Filter[domain.UserField]) { + switch v := f.(type) { + case database.AndFilter[domain.UserField]: + for _, child := range v.Filters { + walk(child) + } + case database.OrFilter[domain.UserField]: + for _, child := range v.Filters { + walk(child) + } + case *database.CompareFilter[domain.UserField]: + if v.Op != database.OpEqual { + return + } + col := database.Col(domain.UserFieldSchemaURL) + for _, term := range v.Terms { + if term.Column != col { + continue + } + url, ok := term.Value.(string) + require.True(t, ok, "schema_url filter value must be a string") + urls = append(urls, url) + } + } + } + walk(filter) + return urls +} + func expectHydrateTeam(s *servicemocks.MockAllStatements, teamID string) { s.EXPECT().ListTeams(gomock.Any(), gomock.Any()).Return(&database.ListResult[*domain.Team]{ Items: []*domain.Team{{ID: teamID}}, From ca75e8ed072c91b359fe365b836fc2f07c07943f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 17:35:25 +0000 Subject: [PATCH 04/14] docs(api): distinguish grant.invalid from req.invalid on locators GrantUser.attributes is user content, not the schema document. Extra locator properties stay additionalProperties: false (req.invalid); XOR of id vs locator remains grant.invalid. Co-authored-by: Silvan --- api/generated/oas_schemas_gen.go | 22 ++++++++----- api/openapi/endpoints/grants/grant-user.yaml | 6 ++-- .../endpoints/grants/team-locator.yaml | 5 +-- .../endpoints/grants/user-locator.yaml | 5 +-- internal/api/integration_test/grant_test.go | 31 +++++++++++++++++++ 5 files changed, 55 insertions(+), 14 deletions(-) diff --git a/api/generated/oas_schemas_gen.go b/api/generated/oas_schemas_gen.go index cdaafbe1c..395235c88 100644 --- a/api/generated/oas_schemas_gen.go +++ b/api/generated/oas_schemas_gen.go @@ -22781,8 +22781,10 @@ type GrantUser struct { // The schema that defines `attributes`. Present only when the request // asked for `expand: ["principal"]` and the user could be loaded. Schema OptString `json:"schema"` - // The user's schema document. Present only when the request asked - // for `expand: ["principal"]` and the user could be loaded. + // The user's content, satisfying the schema named by `schema`. Property + // names and types are determined entirely by that schema. Present only + // when the request asked for `expand: ["principal"]` and the user + // could be loaded. Attributes OptGrantUserAttributes `json:"attributes"` // Server-owned user envelope. Present only when the request asked // for `expand: ["principal"]` and the user could be loaded. Grant @@ -22860,8 +22862,10 @@ func (s *GrantUser) SetMetadata(val OptUserMetadata) { s.Metadata = val } -// The user's schema document. Present only when the request asked -// for `expand: ["principal"]` and the user could be loaded. +// The user's content, satisfying the schema named by `schema`. Property +// names and types are determined entirely by that schema. Present only +// when the request asked for `expand: ["principal"]` and the user +// could be loaded. type GrantUserAttributes map[string]jx.Raw func (s *GrantUserAttributes) init() GrantUserAttributes { @@ -46541,8 +46545,9 @@ func (s *TeamFilterField) UnmarshalText(data []byte) error { type TeamID string -// Name a team with exactly one of `team_id` or `name`. Sending both, -// neither, or any other field is `grant.invalid`. +// Name a team with exactly one of `team_id` or `name`. Sending both +// or neither is `grant.invalid`. Unknown properties are rejected by +// `additionalProperties: false` as `req.invalid` before the handler runs. // Ref: # type TeamLocator struct { // Platform-homed team id (`team_`). The team must be active. @@ -49598,8 +49603,9 @@ func (s *UserInvalidDetails) init() UserInvalidDetails { return m } -// Name a user with exactly one of `user_id` or `identifier`. Sending both, -// neither, or any other field is `grant.invalid`. +// Name a user with exactly one of `user_id` or `identifier`. Sending both +// or neither is `grant.invalid`. Unknown properties are rejected by +// `additionalProperties: false` as `req.invalid` before the handler runs. // Ref: # type UserLocator struct { // Platform-homed user id (`user_`). The user must be active. diff --git a/api/openapi/endpoints/grants/grant-user.yaml b/api/openapi/endpoints/grants/grant-user.yaml index 55fea4725..10e85daed 100644 --- a/api/openapi/endpoints/grants/grant-user.yaml +++ b/api/openapi/endpoints/grants/grant-user.yaml @@ -17,8 +17,10 @@ allOf: type: object additionalProperties: true description: | - The user's schema document. Present only when the request asked - for `expand: ["principal"]` and the user could be loaded. + The user's content, satisfying the schema named by `schema`. Property + names and types are determined entirely by that schema. Present only + when the request asked for `expand: ["principal"]` and the user + could be loaded. metadata: description: | Server-owned user envelope. Present only when the request asked diff --git a/api/openapi/endpoints/grants/team-locator.yaml b/api/openapi/endpoints/grants/team-locator.yaml index b3a738791..221472d48 100644 --- a/api/openapi/endpoints/grants/team-locator.yaml +++ b/api/openapi/endpoints/grants/team-locator.yaml @@ -1,7 +1,8 @@ title: TeamLocator description: | - Name a team with exactly one of `team_id` or `name`. Sending both, - neither, or any other field is `grant.invalid`. + Name a team with exactly one of `team_id` or `name`. Sending both + or neither is `grant.invalid`. Unknown properties are rejected by + `additionalProperties: false` as `req.invalid` before the handler runs. type: object additionalProperties: false properties: diff --git a/api/openapi/endpoints/grants/user-locator.yaml b/api/openapi/endpoints/grants/user-locator.yaml index b05e1ee5f..ac1754e1c 100644 --- a/api/openapi/endpoints/grants/user-locator.yaml +++ b/api/openapi/endpoints/grants/user-locator.yaml @@ -1,7 +1,8 @@ title: UserLocator description: | - Name a user with exactly one of `user_id` or `identifier`. Sending both, - neither, or any other field is `grant.invalid`. + Name a user with exactly one of `user_id` or `identifier`. Sending both + or neither is `grant.invalid`. Unknown properties are rejected by + `additionalProperties: false` as `req.invalid` before the handler runs. type: object additionalProperties: false properties: diff --git a/internal/api/integration_test/grant_test.go b/internal/api/integration_test/grant_test.go index 8ba5f5fbf..0a880cafb 100644 --- a/internal/api/integration_test/grant_test.go +++ b/internal/api/integration_test/grant_test.go @@ -325,6 +325,18 @@ func TestGrantCreateLocators(t *testing.T) { require.True(t, ok, helpers.MustMarshal(t, resp)) assert.Equal(t, api.ErrorCode("grant.invalid"), bad.Code) }) + + t.Run("extra user locator field is req.invalid", func(t *testing.T) { + t.Parallel() + assertCreateGrantReqInvalid(t, client.Token(), project.ID, + `{"relation":"viewer","user":{"user_id":"user_1","extra":"nope"}}`) + }) + + t.Run("extra team locator field is req.invalid", func(t *testing.T) { + t.Parallel() + assertCreateGrantReqInvalid(t, client.Token(), project.ID, + `{"relation":"admin","team":{"team_id":"team_1","extra":"nope"}}`) + }) } func TestGrantQuery(t *testing.T) { @@ -677,6 +689,25 @@ func assertGrantNotFound(t *testing.T, resp any) { } } +func assertCreateGrantReqInvalid(t *testing.T, token, projectID, body string) { + t.Helper() + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, + harness.EnsureTestServer(t).URL+"/grants?project_id="+url.QueryEscape(projectID), + strings.NewReader(body), + ) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp, err := harness.EnsureHttpClient(t).Do(req) + require.NoError(t, err) + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, string(raw)) + details := helpers.MustUnmarshal[api.ErrorDetails](t, raw) + assert.Equal(t, api.ErrorCode("req.invalid"), details.Code) +} + func assertGrantPrincipalNotFound(t *testing.T, resp any) { t.Helper() nf, ok := resp.(*api.CreateGrantNotFound) From 76d1b958f9f7f73669d7993046ccbae59a04b6d9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 22:15:34 +0000 Subject: [PATCH 05/14] test(console): reset SDK config between vitest files configureZitadel is write-once on globalThis, which survives Vitest isolate. A file that bound the DEV `/api` default first caused later specs to fetch localhost:3000 while their MSW handlers waited on http://localhost/api, so the first test in many files timed out in CI. Co-authored-by: Silvan --- apps/console/src/api/api-base.spec.ts | 3 +++ apps/console/src/test-setup.ts | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/apps/console/src/api/api-base.spec.ts b/apps/console/src/api/api-base.spec.ts index b4672897c..391290aa7 100644 --- a/apps/console/src/api/api-base.spec.ts +++ b/apps/console/src/api/api-base.spec.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { _resetConfigForTesting } from "@zitadel/api/config"; + /** * Pins both branches of the API base (Console ADR 0002 §4, revised): the * embedded production build must target the origin root — `/api` exists only @@ -14,6 +16,7 @@ describe("apiBase", () => { afterEach(() => { vi.unstubAllEnvs(); vi.resetModules(); + _resetConfigForTesting(); }); it("targets the origin root in production builds", async () => { diff --git a/apps/console/src/test-setup.ts b/apps/console/src/test-setup.ts index 1408f6f73..f26f00b3d 100644 --- a/apps/console/src/test-setup.ts +++ b/apps/console/src/test-setup.ts @@ -1,5 +1,13 @@ +import { _resetConfigForTesting } from "@zitadel/api/config"; import "@testing-library/jest-dom/vitest"; +// configureZitadel is write-once on globalThis so duplicate module copies +// share one slot. That slot also survives Vitest's per-file isolate, so +// whichever spec first calls configureZitadel keeps its proxyPath for every +// later file in the worker. CI then fetches `http://localhost:3000/api` +// (the DEV relative default) while handlers wait on `http://localhost/api`. +_resetConfigForTesting(); + // @ts-expect-error Needed for tests global.IS_REACT_ACT_ENVIRONMENT = true; From 71a9d6f67b3b55c9a05ba54538849636cfe0d9cb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 14 Sep 2026 13:32:50 +0000 Subject: [PATCH 06/14] refactor(api): share UserAttributes and grant locator helpers Extract UserAttributes so grant expand reuses the user envelope type, normalize locators once, and collapse duplicated schema-list and id resolution helpers. Console admin labels read typed display/identifier. Co-authored-by: Silvan --- api/generated/oas_json_gen.go | 126 ++++----------- api/generated/oas_schemas_gen.go | 145 ++++++++---------- .../components/schemas/user-attributes.yaml | 6 + api/openapi/endpoints/grants/grant-user.yaml | 9 +- .../endpoints/grants/query/grant-expand.yaml | 21 ++- api/openapi/endpoints/users/user.yaml | 6 +- .../src/routes/_authed/settings/admins.tsx | 14 +- internal/api/grant.go | 15 +- internal/api/integration_test/grant_test.go | 4 +- internal/api/user.go | 29 ++-- internal/service/grant.go | 90 ++++------- internal/service/user_ref.go | 37 +++-- 12 files changed, 193 insertions(+), 309 deletions(-) create mode 100644 api/openapi/components/schemas/user-attributes.yaml diff --git a/api/generated/oas_json_gen.go b/api/generated/oas_json_gen.go index 03b93ab55..6564796af 100644 --- a/api/generated/oas_json_gen.go +++ b/api/generated/oas_json_gen.go @@ -41523,64 +41523,6 @@ func (s *GrantUser) UnmarshalJSON(data []byte) error { return s.Decode(d) } -// Encode implements json.Marshaler. -func (s GrantUserAttributes) Encode(e *jx.Encoder) { - e.ObjStart() - s.encodeFields(e) - e.ObjEnd() -} - -// encodeFields implements json.Marshaler. -func (s GrantUserAttributes) encodeFields(e *jx.Encoder) { - for k, elem := range s { - e.FieldStart(k) - - if len(elem) != 0 { - e.Raw(elem) - } - } -} - -// Decode decodes GrantUserAttributes from json. -func (s *GrantUserAttributes) Decode(d *jx.Decoder) error { - if s == nil { - return errors.New("invalid: unable to decode GrantUserAttributes to nil") - } - m := s.init() - if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { - var elem jx.Raw - if err := func() error { - v, err := d.RawAppend(nil) - elem = jx.Raw(v) - if err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrapf(err, "decode field %q", k) - } - m[string(k)] = elem - return nil - }); err != nil { - return errors.Wrap(err, "decode GrantUserAttributes") - } - - return nil -} - -// MarshalJSON implements stdjson.Marshaler. -func (s GrantUserAttributes) MarshalJSON() ([]byte, error) { - e := jx.Encoder{} - s.Encode(&e) - return e.Bytes(), nil -} - -// UnmarshalJSON implements stdjson.Unmarshaler. -func (s *GrantUserAttributes) UnmarshalJSON(data []byte) error { - d := jx.DecodeBytes(data) - return s.Decode(d) -} - // Encode implements json.Marshaler. func (s *HandoffResponse) Encode(e *jx.Encoder) { e.ObjStart() @@ -48773,40 +48715,6 @@ func (s *OptGrantUser) UnmarshalJSON(data []byte) error { return s.Decode(d) } -// Encode encodes GrantUserAttributes as json. -func (o OptGrantUserAttributes) Encode(e *jx.Encoder) { - if !o.Set { - return - } - o.Value.Encode(e) -} - -// Decode decodes GrantUserAttributes from json. -func (o *OptGrantUserAttributes) Decode(d *jx.Decoder) error { - if o == nil { - return errors.New("invalid: unable to decode OptGrantUserAttributes to nil") - } - o.Set = true - o.Value = make(GrantUserAttributes) - if err := o.Value.Decode(d); err != nil { - return err - } - return nil -} - -// MarshalJSON implements stdjson.Marshaler. -func (s OptGrantUserAttributes) MarshalJSON() ([]byte, error) { - e := jx.Encoder{} - s.Encode(&e) - return e.Bytes(), nil -} - -// UnmarshalJSON implements stdjson.Unmarshaler. -func (s *OptGrantUserAttributes) UnmarshalJSON(data []byte) error { - d := jx.DecodeBytes(data) - return s.Decode(d) -} - // Encode encodes int as json. func (o OptInt) Encode(e *jx.Encoder) { if !o.Set { @@ -52649,6 +52557,40 @@ func (s *OptUserAlreadyExistsDetails) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode encodes UserAttributes as json. +func (o OptUserAttributes) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes UserAttributes from json. +func (o *OptUserAttributes) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptUserAttributes to nil") + } + o.Set = true + o.Value = make(UserAttributes) + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptUserAttributes) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptUserAttributes) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode encodes UserCreateFailedEventDelegationType as json. func (o OptUserCreateFailedEventDelegationType) Encode(e *jx.Encoder) { if !o.Set { diff --git a/api/generated/oas_schemas_gen.go b/api/generated/oas_schemas_gen.go index 395235c88..7c2bf9a8a 100644 --- a/api/generated/oas_schemas_gen.go +++ b/api/generated/oas_schemas_gen.go @@ -22231,17 +22231,16 @@ func (s *GrantAlreadyExistsDetails) init() GrantAlreadyExistsDetails { return m } -// Expand the bound user or team on each returned grant (ADR 059, grant -// exception: extras are inlined onto `user` / `team`, not a sibling). -// - `principal`: add User envelope fields (`schema`, `attributes`, -// `metadata`) on `user`, or Team `status` / `created_at` / `updated_at` -// on `team`. The ref (`user_id` / `team_id`) is always present. When the -// principal cannot be loaded, the object stays a degraded ref — the same -// shape as not expanding. -// Requires `user.read` and `team.read` in addition to `project.read`. -// Both are checked on the whole request before the list, because a mixed -// page is the common case. A caller who may not read either resource -// receives 403 rather than silently omitting extras. +// Ask for extra fields on the bound user or team. Those extras land on +// `user` or `team` themselves; there is no sibling `principal` object. +// `principal` adds `schema`, `attributes`, and `metadata` on `user`, or +// `status`, `created_at`, and `updated_at` on `team`. The id (`user_id` / +// `team_id`) is always present. If the user or team cannot be loaded, the +// object stays id-only — the same shape as not expanding. +// Expanding requires `user.read` and `team.read` in addition to +// `project.read`. Both are checked once for the whole request. If the +// caller lacks either, the request is 403; extras are not omitted +// silently. // Ref: # type GrantExpand string @@ -22781,11 +22780,9 @@ type GrantUser struct { // The schema that defines `attributes`. Present only when the request // asked for `expand: ["principal"]` and the user could be loaded. Schema OptString `json:"schema"` - // The user's content, satisfying the schema named by `schema`. Property - // names and types are determined entirely by that schema. Present only - // when the request asked for `expand: ["principal"]` and the user - // could be loaded. - Attributes OptGrantUserAttributes `json:"attributes"` + // Present only when the request asked for `expand: ["principal"]` and + // the user could be loaded. + Attributes OptUserAttributes `json:"attributes"` // Server-owned user envelope. Present only when the request asked // for `expand: ["principal"]` and the user could be loaded. Grant // expand does not populate `lifecycle_owner_team`. @@ -22818,7 +22815,7 @@ func (s *GrantUser) GetSchema() OptString { } // GetAttributes returns the value of Attributes. -func (s *GrantUser) GetAttributes() OptGrantUserAttributes { +func (s *GrantUser) GetAttributes() OptUserAttributes { return s.Attributes } @@ -22853,7 +22850,7 @@ func (s *GrantUser) SetSchema(val OptString) { } // SetAttributes sets the value of Attributes. -func (s *GrantUser) SetAttributes(val OptGrantUserAttributes) { +func (s *GrantUser) SetAttributes(val OptUserAttributes) { s.Attributes = val } @@ -22862,21 +22859,6 @@ func (s *GrantUser) SetMetadata(val OptUserMetadata) { s.Metadata = val } -// The user's content, satisfying the schema named by `schema`. Property -// names and types are determined entirely by that schema. Present only -// when the request asked for `expand: ["principal"]` and the user -// could be loaded. -type GrantUserAttributes map[string]jx.Raw - -func (s *GrantUserAttributes) init() GrantUserAttributes { - m := *s - if m == nil { - m = map[string]jx.Raw{} - *s = m - } - return m -} - // The handoff token and metadata for session exchange. // This is a short-lived credential (TTL ≤ 60 seconds) that the client must exchange // at POST /sessions/exchange to receive the final session and session_token. @@ -29586,52 +29568,6 @@ func (o OptGrantUser) Or(d GrantUser) GrantUser { return d } -// NewOptGrantUserAttributes returns new OptGrantUserAttributes with value set to v. -func NewOptGrantUserAttributes(v GrantUserAttributes) OptGrantUserAttributes { - return OptGrantUserAttributes{ - Value: v, - Set: true, - } -} - -// OptGrantUserAttributes is optional GrantUserAttributes. -type OptGrantUserAttributes struct { - Value GrantUserAttributes - Set bool -} - -// IsSet returns true if OptGrantUserAttributes was set. -func (o OptGrantUserAttributes) IsSet() bool { return o.Set } - -// Reset unsets value. -func (o *OptGrantUserAttributes) Reset() { - var v GrantUserAttributes - o.Value = v - o.Set = false -} - -// SetTo sets value to v. -func (o *OptGrantUserAttributes) SetTo(v GrantUserAttributes) { - o.Set = true - o.Value = v -} - -// Get returns value and boolean that denotes whether value was set. -func (o OptGrantUserAttributes) Get() (v GrantUserAttributes, ok bool) { - if !o.Set { - return v, false - } - return o.Value, true -} - -// Or returns value if set, or given parameter if does not. -func (o OptGrantUserAttributes) Or(d GrantUserAttributes) GrantUserAttributes { - if v, ok := o.Get(); ok { - return v - } - return d -} - // NewOptInt returns new OptInt with value set to v. func NewOptInt(v int) OptInt { return OptInt{ @@ -34946,6 +34882,52 @@ func (o OptUserAlreadyExistsDetails) Or(d UserAlreadyExistsDetails) UserAlreadyE return d } +// NewOptUserAttributes returns new OptUserAttributes with value set to v. +func NewOptUserAttributes(v UserAttributes) OptUserAttributes { + return OptUserAttributes{ + Value: v, + Set: true, + } +} + +// OptUserAttributes is optional UserAttributes. +type OptUserAttributes struct { + Value UserAttributes + Set bool +} + +// IsSet returns true if OptUserAttributes was set. +func (o OptUserAttributes) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptUserAttributes) Reset() { + var v UserAttributes + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptUserAttributes) SetTo(v UserAttributes) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptUserAttributes) Get() (v UserAttributes, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptUserAttributes) Or(d UserAttributes) UserAttributes { + if v, ok := o.Get(); ok { + return v + } + return d +} + // NewOptUserCreateFailedEventDelegationType returns new OptUserCreateFailedEventDelegationType with value set to v. func NewOptUserCreateFailedEventDelegationType(v UserCreateFailedEventDelegationType) OptUserCreateFailedEventDelegationType { return OptUserCreateFailedEventDelegationType{ @@ -47800,9 +47782,7 @@ type User struct { // The schema that defines the content of `attributes`. These schemas can be // created using the `/schemas` endpoint. A default schema is provided. // This schema can be retrieved using the same endpoint. - Schema string `json:"schema"` - // The user's content, satisfying the schema named by `schema`. Property - // names and types are determined entirely by that schema. + Schema string `json:"schema"` Attributes UserAttributes `json:"attributes"` Metadata UserMetadata `json:"metadata"` // The current value of the user schema's designated identifier @@ -47982,6 +47962,7 @@ func (s *UserAlreadyExistsDetails) init() UserAlreadyExistsDetails { // The user's content, satisfying the schema named by `schema`. Property // names and types are determined entirely by that schema. +// Ref: # type UserAttributes map[string]jx.Raw func (s *UserAttributes) init() UserAttributes { diff --git a/api/openapi/components/schemas/user-attributes.yaml b/api/openapi/components/schemas/user-attributes.yaml new file mode 100644 index 000000000..f0991c7c8 --- /dev/null +++ b/api/openapi/components/schemas/user-attributes.yaml @@ -0,0 +1,6 @@ +title: UserAttributes +type: object +additionalProperties: true +description: | + The user's content, satisfying the schema named by `schema`. Property + names and types are determined entirely by that schema. diff --git a/api/openapi/endpoints/grants/grant-user.yaml b/api/openapi/endpoints/grants/grant-user.yaml index 10e85daed..32153d2a9 100644 --- a/api/openapi/endpoints/grants/grant-user.yaml +++ b/api/openapi/endpoints/grants/grant-user.yaml @@ -14,13 +14,10 @@ allOf: The schema that defines `attributes`. Present only when the request asked for `expand: ["principal"]` and the user could be loaded. attributes: - type: object - additionalProperties: true description: | - The user's content, satisfying the schema named by `schema`. Property - names and types are determined entirely by that schema. Present only - when the request asked for `expand: ["principal"]` and the user - could be loaded. + Present only when the request asked for `expand: ["principal"]` and + the user could be loaded. + $ref: ../../components/schemas/user-attributes.yaml metadata: description: | Server-owned user envelope. Present only when the request asked diff --git a/api/openapi/endpoints/grants/query/grant-expand.yaml b/api/openapi/endpoints/grants/query/grant-expand.yaml index 2d3b618c7..a170626b7 100644 --- a/api/openapi/endpoints/grants/query/grant-expand.yaml +++ b/api/openapi/endpoints/grants/query/grant-expand.yaml @@ -1,17 +1,16 @@ type: string description: | - Expand the bound user or team on each returned grant (ADR 059, grant - exception: extras are inlined onto `user` / `team`, not a sibling). + Ask for extra fields on the bound user or team. Those extras land on + `user` or `team` themselves; there is no sibling `principal` object. - - `principal`: add User envelope fields (`schema`, `attributes`, - `metadata`) on `user`, or Team `status` / `created_at` / `updated_at` - on `team`. The ref (`user_id` / `team_id`) is always present. When the - principal cannot be loaded, the object stays a degraded ref — the same - shape as not expanding. + `principal` adds `schema`, `attributes`, and `metadata` on `user`, or + `status`, `created_at`, and `updated_at` on `team`. The id (`user_id` / + `team_id`) is always present. If the user or team cannot be loaded, the + object stays id-only — the same shape as not expanding. - Requires `user.read` and `team.read` in addition to `project.read`. - Both are checked on the whole request before the list, because a mixed - page is the common case. A caller who may not read either resource - receives 403 rather than silently omitting extras. + Expanding requires `user.read` and `team.read` in addition to + `project.read`. Both are checked once for the whole request. If the + caller lacks either, the request is 403; extras are not omitted + silently. enum: - principal diff --git a/api/openapi/endpoints/users/user.yaml b/api/openapi/endpoints/users/user.yaml index 7f58953a0..6a5fa4e61 100644 --- a/api/openapi/endpoints/users/user.yaml +++ b/api/openapi/endpoints/users/user.yaml @@ -28,11 +28,7 @@ properties: created using the `/schemas` endpoint. A default schema is provided. This schema can be retrieved using the same endpoint. attributes: - type: object - additionalProperties: true - description: | - The user's content, satisfying the schema named by `schema`. Property - names and types are determined entirely by that schema. + $ref: ../../components/schemas/user-attributes.yaml metadata: readOnly: true $ref: user-metadata.yaml diff --git a/apps/console/src/routes/_authed/settings/admins.tsx b/apps/console/src/routes/_authed/settings/admins.tsx index 2433ca354..5dd4b2f80 100644 --- a/apps/console/src/routes/_authed/settings/admins.tsx +++ b/apps/console/src/routes/_authed/settings/admins.tsx @@ -27,7 +27,6 @@ import { } from "@/components/ui/table"; import { api } from "../../../api/zitadel"; -import { field } from "../../../lib/record"; import { getConsoleProjectId } from "../../../runtime/runtime"; /** @@ -168,9 +167,9 @@ function AdminsScreen() { * always exists — and which is what the operator needs to know which grant * they are revoking. * - * Discriminate on which of `user` or `team` is present. They are separate - * objects and the type ties neither to a kind field, so the values are read - * the way the console reads every other open record: defensively, by name. + * Discriminate on which of `user` or `team` is present. Label from the + * team's name or the user's `display` / `identifier`; the id is always + * the last fallback in `toAdminRow`. */ function toAdminRow(grant: Grant): AdminRow { return { @@ -181,12 +180,7 @@ function toAdminRow(grant: Grant): AdminRow { } function principalName(grant: Grant): string | undefined { - if (grant.team) { - return grant.team.name; - } - if (!grant.user) return undefined; - const user = grant.user as unknown as Record; - return field(user, "display") ?? field(user, "identifier"); + return grant.team?.name ?? grant.user?.display ?? grant.user?.identifier; } /** diff --git a/internal/api/grant.go b/internal/api/grant.go index 72f6836f3..4c9cc9672 100644 --- a/internal/api/grant.go +++ b/internal/api/grant.go @@ -200,23 +200,12 @@ func grantUserResponse(g *service.Grant) (api.GrantUser, error) { if err != nil { return out, domain.ErrInternal(err).WithMessage("failed to parse user attributes") } - attributes, err := convertUsingJson[api.GrantUserAttributes](userData) + attributes, err := convertUsingJson[api.UserAttributes](userData) if err != nil { return out, err } out.Attributes.SetTo(*attributes) - var lifecycleOwnerTeamID api.OptNilString - if teamID, ok := u.OwningTeamID(); ok { - lifecycleOwnerTeamID.SetTo(teamID) - } else { - lifecycleOwnerTeamID.SetToNull() - } - out.Metadata.SetTo(api.UserMetadata{ - CreatedAt: u.Metadata.CreatedAt, - UpdatedAt: u.Metadata.UpdatedAt, - Status: api.UserMetadataStatus(u.Metadata.Status), - LifecycleOwnerTeamID: lifecycleOwnerTeamID, - }) + out.Metadata.SetTo(userMetadataToAPI(u)) return out, nil } diff --git a/internal/api/integration_test/grant_test.go b/internal/api/integration_test/grant_test.go index 0a880cafb..4fd1f57c3 100644 --- a/internal/api/integration_test/grant_test.go +++ b/internal/api/integration_test/grant_test.go @@ -587,9 +587,7 @@ func TestGrantQueryExpand(t *testing.T) { wantUser, ok := getUser.(*api.User) require.True(t, ok, helpers.MustMarshal(t, getUser)) assert.Equal(t, wantUser.Schema, listedUser.User.Value.Schema.Value) - // Grant expand inlines attributes onto GrantUser, so the generated type - // is GrantUserAttributes rather than UserAttributes; compare the JSON. - assert.JSONEq(t, helpers.MustMarshal(t, wantUser.Attributes), helpers.MustMarshal(t, listedUser.User.Value.Attributes.Value)) + assert.Equal(t, wantUser.Attributes, listedUser.User.Value.Attributes.Value) assert.Equal(t, wantUser.Metadata.Status, listedUser.User.Value.Metadata.Value.Status) listedTeam := got[teamGrant.ID] diff --git a/internal/api/user.go b/internal/api/user.go index 0b4a762e1..dfd78bee0 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -292,6 +292,21 @@ func userRefToAPI(ref domain.UserRef) api.UserRef { return out } +func userMetadataToAPI(user *domain.User) api.UserMetadata { + var lifecycleOwnerTeamID api.OptNilString + if teamID, ok := user.OwningTeamID(); ok { + lifecycleOwnerTeamID.SetTo(teamID) + } else { + lifecycleOwnerTeamID.SetToNull() + } + return api.UserMetadata{ + CreatedAt: user.Metadata.CreatedAt, + UpdatedAt: user.Metadata.UpdatedAt, + Status: api.UserMetadataStatus(user.Metadata.Status), + LifecycleOwnerTeamID: lifecycleOwnerTeamID, + } +} + func domainUserToApiUser(user *domain.User) (*api.User, error) { userData, err := user.Attributes.ToMap() if err != nil { @@ -303,23 +318,11 @@ func domainUserToApiUser(user *domain.User) (*api.User, error) { return nil, err } - var lifecycleOwnerTeamID api.OptNilString - if teamID, ok := user.OwningTeamID(); ok { - lifecycleOwnerTeamID.SetTo(teamID) - } else { - lifecycleOwnerTeamID.SetToNull() - } - out := &api.User{ ID: api.UserID(user.ID), Schema: user.SchemaURL, Attributes: *attributes, - Metadata: api.UserMetadata{ - CreatedAt: user.Metadata.CreatedAt, - UpdatedAt: user.Metadata.UpdatedAt, - Status: api.UserMetadataStatus(user.Metadata.Status), - LifecycleOwnerTeamID: lifecycleOwnerTeamID, - }, + Metadata: userMetadataToAPI(user), } // The derived identity of ADR 058 §3a: identifier and identifier_property diff --git a/internal/service/grant.go b/internal/service/grant.go index 6dff80040..db6e616ce 100644 --- a/internal/service/grant.go +++ b/internal/service/grant.go @@ -68,6 +68,10 @@ type CreateGrantInput struct { } func (s *GrantService) Create(ctx context.Context, input CreateGrantInput) (*Grant, error) { + input.UserID = strings.TrimSpace(input.UserID) + input.Identifier = strings.TrimSpace(input.Identifier) + input.TeamID = strings.TrimSpace(input.TeamID) + input.TeamName = strings.TrimSpace(input.TeamName) if err := validateCreateGrant(input); err != nil { return nil, err } @@ -168,30 +172,26 @@ func (s *GrantService) Revoke(ctx context.Context, projectID, id string) error { } func validateCreateGrant(input CreateGrantInput) error { - userID := strings.TrimSpace(input.UserID) - identifier := strings.TrimSpace(input.Identifier) - teamID := strings.TrimSpace(input.TeamID) - teamName := strings.TrimSpace(input.TeamName) n := 0 - if userID != "" { + if input.UserID != "" { n++ } - if identifier != "" { + if input.Identifier != "" { n++ } - if teamID != "" { + if input.TeamID != "" { n++ } - if teamName != "" { + if input.TeamName != "" { n++ } if n != 1 { return domain.ErrGrantInvalid().WithDetails("exactly one of user.user_id, user.identifier, team.team_id, or team.name is required") } - if userID != "" && !domain.PrefixUser.Matches(userID) { + if input.UserID != "" && !domain.PrefixUser.Matches(input.UserID) { return domain.ErrGrantInvalid().WithDetails("user_id must use the user_ prefix") } - if teamID != "" && !domain.PrefixTeam.Matches(teamID) { + if input.TeamID != "" && !domain.PrefixTeam.Matches(input.TeamID) { return domain.ErrGrantInvalid().WithDetails("team_id must use the team_ prefix") } if _, ok := allowedGrantRelations[input.Relation]; !ok { @@ -212,34 +212,18 @@ func (s *GrantService) locatorHome(grantProjectID string) string { func (s *GrantService) resolveLocator(ctx context.Context, stmts AllStatements, input CreateGrantInput) (domain.AuthzPrincipalType, string, error) { switch { - case strings.TrimSpace(input.UserID) != "": - userID := strings.TrimSpace(input.UserID) - home, err := s.resolvePrincipalHome(ctx, stmts, domain.AuthzPrincipalTypeUser, userID) - if err != nil { - return "", "", err - } - if err := s.loadPrincipal(ctx, stmts, home, domain.AuthzPrincipalTypeUser, userID); err != nil { - return "", "", err - } - return domain.AuthzPrincipalTypeUser, userID, nil - case strings.TrimSpace(input.Identifier) != "": - id, err := s.resolveUserByIdentifier(ctx, stmts, s.locatorHome(input.ProjectID), strings.TrimSpace(input.Identifier)) + case input.UserID != "": + return s.resolveByID(ctx, stmts, domain.AuthzPrincipalTypeUser, input.UserID) + case input.Identifier != "": + id, err := s.resolveUserByIdentifier(ctx, stmts, s.locatorHome(input.ProjectID), input.Identifier) if err != nil { return "", "", err } return domain.AuthzPrincipalTypeUser, id, nil - case strings.TrimSpace(input.TeamID) != "": - teamID := strings.TrimSpace(input.TeamID) - home, err := s.resolvePrincipalHome(ctx, stmts, domain.AuthzPrincipalTypeTeam, teamID) - if err != nil { - return "", "", err - } - if err := s.loadPrincipal(ctx, stmts, home, domain.AuthzPrincipalTypeTeam, teamID); err != nil { - return "", "", err - } - return domain.AuthzPrincipalTypeTeam, teamID, nil - case strings.TrimSpace(input.TeamName) != "": - id, err := s.resolveTeamByName(ctx, stmts, s.locatorHome(input.ProjectID), strings.TrimSpace(input.TeamName)) + case input.TeamID != "": + return s.resolveByID(ctx, stmts, domain.AuthzPrincipalTypeTeam, input.TeamID) + case input.TeamName != "": + id, err := s.resolveTeamByName(ctx, stmts, s.locatorHome(input.ProjectID), input.TeamName) if err != nil { return "", "", err } @@ -249,16 +233,25 @@ func (s *GrantService) resolveLocator(ctx context.Context, stmts AllStatements, } } +func (s *GrantService) resolveByID(ctx context.Context, stmts AllStatements, principalType domain.AuthzPrincipalType, id string) (domain.AuthzPrincipalType, string, error) { + home, err := s.resolvePrincipalHome(ctx, stmts, principalType, id) + if err != nil { + return "", "", err + } + if err := s.loadPrincipal(ctx, stmts, home, principalType, id); err != nil { + return "", "", err + } + return principalType, id, nil +} + func (s *GrantService) resolveUserByIdentifier(ctx context.Context, stmts AllStatements, home, identifier string) (string, error) { urlsByKey, err := s.designatedIdentifierKeys(ctx, stmts, home) if err != nil { return "", err } found := map[string]struct{}{} + var matchID string for key, urls := range urlsByKey { - if len(urls) == 0 { - continue - } user, err := stmts.GetUser(ctx, database.And( database.Equal(database.Col(domain.UserFieldProjectID), home), database.Equal(database.Col(domain.UserFieldStatus), domain.UserStatusActive.String()), @@ -281,6 +274,7 @@ func (s *GrantService) resolveUserByIdentifier(ctx context.Context, stmts AllSta return "", err } found[user.ID] = struct{}{} + matchID = user.ID } if len(found) != 1 { if len(found) > 1 { @@ -291,10 +285,7 @@ func (s *GrantService) resolveUserByIdentifier(ctx context.Context, stmts AllSta } return "", domain.ErrGrantPrincipalNotFound() } - for id := range found { - return id, nil - } - return "", domain.ErrGrantPrincipalNotFound() + return matchID, nil } // designatedIdentifierKeys maps each x-identifier property to the schema URLs @@ -303,22 +294,7 @@ func (s *GrantService) resolveUserByIdentifier(ctx context.Context, stmts AllSta // for example) cannot be selected. func (s *GrantService) designatedIdentifierKeys(ctx context.Context, stmts AllStatements, projectID string) (map[string][]string, error) { ctx = WithAuthzListUnrestricted(ctx) - list := func(cursor []byte) (*database.ListResult[*domain.JSONSchema], error) { - return stmts.ListJSONSchemas(ctx, &database.ListOptions[domain.JSONSchemaField]{ - Filter: database.And( - database.Equal(database.Col(domain.JSONSchemaFieldProjectID), projectID), - database.Equal(database.Col(domain.JSONSchemaFieldKind), domain.JSONSchemaKindUserSchema.String()), - ), - Pagination: database.Page[domain.JSONSchemaField]{ - Limit: refSchemaPageSize, - Cursor: cursor, - OrderBy: database.OrderBy[domain.JSONSchemaField]{ - Columns: []database.Column[domain.JSONSchemaField]{database.Col(domain.JSONSchemaFieldURL)}, - Direction: database.OrderAsc, - }, - }, - }, JSONSchemaQueryOptions{}) - } + list := listUserSchemas(ctx, stmts, projectID) first, err := list(nil) if err != nil { return nil, err diff --git a/internal/service/user_ref.go b/internal/service/user_ref.go index f7661a1de..ab25df80e 100644 --- a/internal/service/user_ref.go +++ b/internal/service/user_ref.go @@ -23,6 +23,25 @@ type UserRefResolver interface { // Projects hold few schemas; paging is correctness, not tuning. const refSchemaPageSize = 100 +func listUserSchemas(ctx context.Context, stmts AllStatements, projectID string) func(cursor []byte) (*database.ListResult[*domain.JSONSchema], error) { + return func(cursor []byte) (*database.ListResult[*domain.JSONSchema], error) { + return stmts.ListJSONSchemas(ctx, &database.ListOptions[domain.JSONSchemaField]{ + Filter: database.And( + database.Equal(database.Col(domain.JSONSchemaFieldProjectID), projectID), + database.Equal(database.Col(domain.JSONSchemaFieldKind), domain.JSONSchemaKindUserSchema.String()), + ), + Pagination: database.Page[domain.JSONSchemaField]{ + Limit: refSchemaPageSize, + Cursor: cursor, + OrderBy: database.OrderBy[domain.JSONSchemaField]{ + Columns: []database.Column[domain.JSONSchemaField]{database.Col(domain.JSONSchemaFieldURL)}, + Direction: database.OrderAsc, + }, + }, + }, JSONSchemaQueryOptions{}) + } +} + // StatementsUserRefResolver resolves refs against the statement surface. type StatementsUserRefResolver struct { Pool StatementPool @@ -108,23 +127,7 @@ func (r StatementsUserRefResolver) ResolveRefsForUsers(ctx context.Context, proj // for the batched user query. A user whose schema URL is not stored (or // designates nothing) resolves to a bare user-id ref. func (r StatementsUserRefResolver) designatingSchemas(ctx context.Context, projectID string) (map[string][]byte, []string, error) { - stmts := r.Pool.Statements() - list := func(cursor []byte) (*database.ListResult[*domain.JSONSchema], error) { - return stmts.ListJSONSchemas(ctx, &database.ListOptions[domain.JSONSchemaField]{ - Filter: database.And( - database.Equal(database.Col(domain.JSONSchemaFieldProjectID), projectID), - database.Equal(database.Col(domain.JSONSchemaFieldKind), domain.JSONSchemaKindUserSchema.String()), - ), - Pagination: database.Page[domain.JSONSchemaField]{ - Limit: refSchemaPageSize, - Cursor: cursor, - OrderBy: database.OrderBy[domain.JSONSchemaField]{ - Columns: []database.Column[domain.JSONSchemaField]{database.Col(domain.JSONSchemaFieldURL)}, - Direction: database.OrderAsc, - }, - }, - }, JSONSchemaQueryOptions{}) - } + list := listUserSchemas(ctx, r.Pool.Statements(), projectID) first, err := list(nil) if err != nil { return nil, nil, err From bd355d1a2e20f68e0d56503a88b8b8e4bfc2f885 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 14 Sep 2026 13:32:50 +0000 Subject: [PATCH 07/14] test(api): use testify and match grant locator filters Convert handler unit tests to require/assert and match GetUser, GetTeam, and ListJSONSchemas filters in locator tests instead of gomock.Any. Co-authored-by: Silvan --- internal/api/grant_internal_test.go | 112 +++++++++------------------- internal/service/grant_test.go | 60 +++++++++++---- 2 files changed, 82 insertions(+), 90 deletions(-) diff --git a/internal/api/grant_internal_test.go b/internal/api/grant_internal_test.go index 940417172..61aa07b75 100644 --- a/internal/api/grant_internal_test.go +++ b/internal/api/grant_internal_test.go @@ -1,9 +1,11 @@ package api import ( - "errors" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + api "github.com/zitadel/nextgen/api/generated" "github.com/zitadel/nextgen/internal/domain" "github.com/zitadel/nextgen/internal/service" @@ -21,9 +23,7 @@ func TestMapQueryGrantsToService_Expand(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { input := mapQueryGrantsToService("proj_a", &api.QueryGrantsRequest{Expand: tt.expand}) - if input.IncludePrincipal != tt.wantIncl { - t.Fatalf("IncludePrincipal = %v, want %v", input.IncludePrincipal, tt.wantIncl) - } + assert.Equal(t, tt.wantIncl, input.IncludePrincipal) }) } } @@ -36,12 +36,10 @@ func TestCreateGrantInput_Locators(t *testing.T) { UserID: api.NewOptUserID("user_1"), }), }) - if err != nil { - t.Fatal(err) - } - if got.UserID != "user_1" || got.Identifier != "" || got.TeamID != "" { - t.Fatalf("got %+v", got) - } + require.NoError(t, err) + assert.Equal(t, "user_1", got.UserID) + assert.Empty(t, got.Identifier) + assert.Empty(t, got.TeamID) }) t.Run("user identifier", func(t *testing.T) { got, err := createGrantInput("proj_a", &api.CreateGrantRequest{ @@ -50,12 +48,9 @@ func TestCreateGrantInput_Locators(t *testing.T) { Identifier: api.NewOptString("alice@acme.com"), }), }) - if err != nil { - t.Fatal(err) - } - if got.Identifier != "alice@acme.com" || got.UserID != "" { - t.Fatalf("got %+v", got) - } + require.NoError(t, err) + assert.Equal(t, "alice@acme.com", got.Identifier) + assert.Empty(t, got.UserID) }) t.Run("team id", func(t *testing.T) { got, err := createGrantInput("proj_a", &api.CreateGrantRequest{ @@ -64,12 +59,9 @@ func TestCreateGrantInput_Locators(t *testing.T) { TeamID: api.NewOptTeamID("team_1"), }), }) - if err != nil { - t.Fatal(err) - } - if got.TeamID != "team_1" || got.TeamName != "" { - t.Fatalf("got %+v", got) - } + require.NoError(t, err) + assert.Equal(t, "team_1", got.TeamID) + assert.Empty(t, got.TeamName) }) t.Run("team name", func(t *testing.T) { got, err := createGrantInput("proj_a", &api.CreateGrantRequest{ @@ -78,18 +70,13 @@ func TestCreateGrantInput_Locators(t *testing.T) { Name: api.NewOptString("Acme AI Admins"), }), }) - if err != nil { - t.Fatal(err) - } - if got.TeamName != "Acme AI Admins" || got.TeamID != "" { - t.Fatalf("got %+v", got) - } + require.NoError(t, err) + assert.Equal(t, "Acme AI Admins", got.TeamName) + assert.Empty(t, got.TeamID) }) t.Run("neither user nor team", func(t *testing.T) { _, err := createGrantInput("proj_a", &api.CreateGrantRequest{Relation: api.CreateGrantRequestRelationViewer}) - if !errors.Is(err, domain.ErrGrantInvalid()) { - t.Fatalf("error = %v, want grant.invalid", err) - } + require.ErrorIs(t, err, domain.ErrGrantInvalid()) }) t.Run("both user and team", func(t *testing.T) { _, err := createGrantInput("proj_a", &api.CreateGrantRequest{ @@ -97,9 +84,7 @@ func TestCreateGrantInput_Locators(t *testing.T) { User: api.NewOptUserLocator(api.UserLocator{UserID: api.NewOptUserID("user_1")}), Team: api.NewOptTeamLocator(api.TeamLocator{TeamID: api.NewOptTeamID("team_1")}), }) - if !errors.Is(err, domain.ErrGrantInvalid()) { - t.Fatalf("error = %v, want grant.invalid", err) - } + require.ErrorIs(t, err, domain.ErrGrantInvalid()) }) t.Run("user both fields", func(t *testing.T) { _, err := createGrantInput("proj_a", &api.CreateGrantRequest{ @@ -109,9 +94,7 @@ func TestCreateGrantInput_Locators(t *testing.T) { Identifier: api.NewOptString("alice@acme.com"), }), }) - if !errors.Is(err, domain.ErrGrantInvalid()) { - t.Fatalf("error = %v, want grant.invalid", err) - } + require.ErrorIs(t, err, domain.ErrGrantInvalid()) }) t.Run("team both fields", func(t *testing.T) { _, err := createGrantInput("proj_a", &api.CreateGrantRequest{ @@ -121,18 +104,14 @@ func TestCreateGrantInput_Locators(t *testing.T) { Name: api.NewOptString("Acme AI Admins"), }), }) - if !errors.Is(err, domain.ErrGrantInvalid()) { - t.Fatalf("error = %v, want grant.invalid", err) - } + require.ErrorIs(t, err, domain.ErrGrantInvalid()) }) t.Run("empty user locator", func(t *testing.T) { _, err := createGrantInput("proj_a", &api.CreateGrantRequest{ Relation: api.CreateGrantRequestRelationViewer, User: api.NewOptUserLocator(api.UserLocator{}), }) - if !errors.Is(err, domain.ErrGrantInvalid()) { - t.Fatalf("error = %v, want grant.invalid", err) - } + require.ErrorIs(t, err, domain.ErrGrantInvalid()) }) } @@ -149,21 +128,11 @@ func TestGrantResponse_UserAndTeam(t *testing.T) { t.Run("ref only when Principal is nil", func(t *testing.T) { resp, err := grantResponse(&service.Grant{Assignment: asgn, User: userRef}) - if err != nil { - t.Fatal(err) - } - if !resp.User.IsSet() { - t.Fatal("user should be set") - } - if resp.User.Value.UserID != "user_1" { - t.Fatalf("user_id = %s", resp.User.Value.UserID) - } - if resp.User.Value.Schema.IsSet() { - t.Fatal("schema should be omitted without expand") - } - if resp.Team.IsSet() { - t.Fatal("team should be omitted on a user grant") - } + require.NoError(t, err) + require.True(t, resp.User.IsSet()) + assert.Equal(t, api.UserID("user_1"), resp.User.Value.UserID) + assert.False(t, resp.User.Value.Schema.IsSet()) + assert.False(t, resp.Team.IsSet()) }) t.Run("expand extras on user when Principal is loaded", func(t *testing.T) { resp, err := grantResponse(&service.Grant{ @@ -177,12 +146,9 @@ func TestGrantResponse_UserAndTeam(t *testing.T) { }, }, }) - if err != nil { - t.Fatal(err) - } - if !resp.User.Value.Schema.IsSet() || resp.User.Value.Schema.Value != "sch_1" { - t.Fatalf("schema = %+v", resp.User.Value.Schema) - } + require.NoError(t, err) + require.True(t, resp.User.Value.Schema.IsSet()) + assert.Equal(t, "sch_1", resp.User.Value.Schema.Value) }) t.Run("degraded ref when expand asked but user missing", func(t *testing.T) { resp, err := grantResponse(&service.Grant{ @@ -190,20 +156,12 @@ func TestGrantResponse_UserAndTeam(t *testing.T) { User: &domain.UserRef{UserID: "user_1"}, Principal: &service.GrantPrincipal{}, }) - if err != nil { - t.Fatal(err) - } - if resp.User.Value.UserID != "user_1" { - t.Fatalf("user_id = %s", resp.User.Value.UserID) - } - if resp.User.Value.Schema.IsSet() { - t.Fatal("schema should stay off a degraded ref") - } + require.NoError(t, err) + assert.Equal(t, api.UserID("user_1"), resp.User.Value.UserID) + assert.False(t, resp.User.Value.Schema.IsSet()) }) t.Run("nil grant", func(t *testing.T) { _, err := grantResponse(nil) - if !errors.Is(err, domain.ErrGrantNotFound()) { - t.Fatalf("error = %v, want grant not found", err) - } + require.ErrorIs(t, err, domain.ErrGrantNotFound()) }) -} +} \ No newline at end of file diff --git a/internal/service/grant_test.go b/internal/service/grant_test.go index b8952b85e..50b142d14 100644 --- a/internal/service/grant_test.go +++ b/internal/service/grant_test.go @@ -220,7 +220,7 @@ func TestGrantService_CreateLocators(t *testing.T) { t.Run("identifier locates active user", func(t *testing.T) { t.Parallel() svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { - s.EXPECT().ListJSONSchemas(gomock.Any(), gomock.Any(), gomock.Any()).Return(&database.ListResult[*domain.JSONSchema]{ + s.EXPECT().ListJSONSchemas(gomock.Any(), userSchemaListFilter(), gomock.Any()).Return(&database.ListResult[*domain.JSONSchema]{ Items: []*domain.JSONSchema{{ ProjectID: grantPlatformProjID, URL: "https://s/human", @@ -228,7 +228,7 @@ func TestGrantService_CreateLocators(t *testing.T) { Schema: []byte(schemaDoc), }}, }, nil) - s.EXPECT().GetUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(&domain.User{ + s.EXPECT().GetUser(gomock.Any(), userLocatorFilter(true), gomock.Any()).Return(&domain.User{ ProjectID: grantPlatformProjID, ID: userID, Metadata: domain.UserMetadata{Status: domain.UserStatusActive}, @@ -257,7 +257,7 @@ func TestGrantService_CreateLocators(t *testing.T) { t.Run("identifier miss is not found", func(t *testing.T) { t.Parallel() svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { - s.EXPECT().ListJSONSchemas(gomock.Any(), gomock.Any(), gomock.Any()).Return(&database.ListResult[*domain.JSONSchema]{ + s.EXPECT().ListJSONSchemas(gomock.Any(), userSchemaListFilter(), gomock.Any()).Return(&database.ListResult[*domain.JSONSchema]{ Items: []*domain.JSONSchema{{ ProjectID: grantPlatformProjID, URL: "https://s/human", @@ -265,7 +265,7 @@ func TestGrantService_CreateLocators(t *testing.T) { Schema: []byte(schemaDoc), }}, }, nil) - s.EXPECT().GetUser(gomock.Any(), gomock.Any(), gomock.Any()). + s.EXPECT().GetUser(gomock.Any(), userLocatorFilter(true), gomock.Any()). Return(nil, database.NewNoRowFoundError(nil)) }) got, err := svc.Create(t.Context(), service.CreateGrantInput{ @@ -280,7 +280,7 @@ func TestGrantService_CreateLocators(t *testing.T) { t.Run("ambiguous identifier is not found", func(t *testing.T) { t.Parallel() svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { - s.EXPECT().ListJSONSchemas(gomock.Any(), gomock.Any(), gomock.Any()).Return(&database.ListResult[*domain.JSONSchema]{ + s.EXPECT().ListJSONSchemas(gomock.Any(), userSchemaListFilter(), gomock.Any()).Return(&database.ListResult[*domain.JSONSchema]{ Items: []*domain.JSONSchema{ { ProjectID: grantPlatformProjID, @@ -296,10 +296,10 @@ func TestGrantService_CreateLocators(t *testing.T) { }, }, }, nil) - s.EXPECT().GetUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(&domain.User{ + s.EXPECT().GetUser(gomock.Any(), userLocatorFilter(true), gomock.Any()).Return(&domain.User{ ID: "user_email_match", Metadata: domain.UserMetadata{Status: domain.UserStatusActive}, }, nil) - s.EXPECT().GetUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(&domain.User{ + s.EXPECT().GetUser(gomock.Any(), userLocatorFilter(true), gomock.Any()).Return(&domain.User{ ID: "user_username_match", Metadata: domain.UserMetadata{Status: domain.UserStatusActive}, }, nil) }) @@ -315,7 +315,7 @@ func TestGrantService_CreateLocators(t *testing.T) { t.Run("identifier skips unique value on undesignated schema", func(t *testing.T) { t.Parallel() svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { - s.EXPECT().ListJSONSchemas(gomock.Any(), gomock.Any(), gomock.Any()).Return(&database.ListResult[*domain.JSONSchema]{ + s.EXPECT().ListJSONSchemas(gomock.Any(), userSchemaListFilter(), gomock.Any()).Return(&database.ListResult[*domain.JSONSchema]{ Items: []*domain.JSONSchema{ { ProjectID: grantPlatformProjID, @@ -331,7 +331,7 @@ func TestGrantService_CreateLocators(t *testing.T) { }, }, }, nil) - s.EXPECT().GetUser(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + s.EXPECT().GetUser(gomock.Any(), userLocatorFilter(true), gomock.Any()).DoAndReturn( func(_ context.Context, filter database.Filter[domain.UserField], opts service.UserQueryOptions) (*domain.User, error) { assert.True(t, filter.Restricts(database.Col(domain.UserFieldSchemaURL))) assert.Equal(t, []string{"https://s/human"}, schemaURLEquals(t, filter)) @@ -367,7 +367,7 @@ func TestGrantService_CreateLocators(t *testing.T) { t.Run("identifier matches among schemas that share a designation", func(t *testing.T) { t.Parallel() svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { - s.EXPECT().ListJSONSchemas(gomock.Any(), gomock.Any(), gomock.Any()).Return(&database.ListResult[*domain.JSONSchema]{ + s.EXPECT().ListJSONSchemas(gomock.Any(), userSchemaListFilter(), gomock.Any()).Return(&database.ListResult[*domain.JSONSchema]{ Items: []*domain.JSONSchema{ { ProjectID: grantPlatformProjID, @@ -383,7 +383,7 @@ func TestGrantService_CreateLocators(t *testing.T) { }, }, }, nil) - s.EXPECT().GetUser(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + s.EXPECT().GetUser(gomock.Any(), userLocatorFilter(true), gomock.Any()).DoAndReturn( func(_ context.Context, filter database.Filter[domain.UserField], opts service.UserQueryOptions) (*domain.User, error) { assert.True(t, filter.Restricts(database.Col(domain.UserFieldSchemaURL))) assert.ElementsMatch(t, []string{"https://s/human", "https://s/admin"}, schemaURLEquals(t, filter)) @@ -419,7 +419,7 @@ func TestGrantService_CreateLocators(t *testing.T) { t.Run("team name locates active team", func(t *testing.T) { t.Parallel() svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { - s.EXPECT().GetTeam(gomock.Any(), gomock.Any()).Return(&domain.Team{ + s.EXPECT().GetTeam(gomock.Any(), teamLocatorFilter(true)).Return(&domain.Team{ ProjectID: grantPlatformProjID, ID: teamID, Name: "Acme AI Admins", @@ -450,7 +450,7 @@ func TestGrantService_CreateLocators(t *testing.T) { t.Run("team name miss is not found", func(t *testing.T) { t.Parallel() svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { - s.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + s.EXPECT().GetTeam(gomock.Any(), teamLocatorFilter(true)). Return(nil, database.NewNoRowFoundError(nil)) }) got, err := svc.Create(t.Context(), service.CreateGrantInput{ @@ -736,6 +736,40 @@ func expectActiveTeamPrincipal(s *servicemocks.MockAllStatements, teamID string) }, nil) } +func userLocatorFilter(schemaURL bool) gomock.Matcher { + return gomock.Cond(func(filter database.Filter[domain.UserField]) bool { + if filter == nil { + return false + } + if !filter.Restricts(database.Col(domain.UserFieldProjectID)) || + !filter.Restricts(database.Col(domain.UserFieldStatus)) { + return false + } + return !schemaURL || filter.Restricts(database.Col(domain.UserFieldSchemaURL)) + }) +} + +func teamLocatorFilter(name bool) gomock.Matcher { + return gomock.Cond(func(filter database.Filter[domain.TeamField]) bool { + if filter == nil { + return false + } + if !filter.Restricts(database.Col(domain.TeamFieldProjectID)) || + !filter.Restricts(database.Col(domain.TeamFieldStatus)) { + return false + } + return !name || filter.Restricts(database.Col(domain.TeamFieldName)) + }) +} + +func userSchemaListFilter() gomock.Matcher { + return gomock.Cond(func(opts *database.ListOptions[domain.JSONSchemaField]) bool { + return opts != nil && opts.Filter != nil && + opts.Filter.Restricts(database.Col(domain.JSONSchemaFieldProjectID)) && + opts.Filter.Restricts(database.Col(domain.JSONSchemaFieldKind)) + }) +} + func schemaURLEquals(t *testing.T, filter database.Filter[domain.UserField]) []string { t.Helper() var urls []string From 845d1afec221c1830453defc2c036fc10baa5015 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 14 Sep 2026 13:35:12 +0000 Subject: [PATCH 08/14] test(api): add trailing newline to grant handler tests Co-authored-by: Silvan --- internal/api/grant_internal_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/grant_internal_test.go b/internal/api/grant_internal_test.go index 61aa07b75..013851589 100644 --- a/internal/api/grant_internal_test.go +++ b/internal/api/grant_internal_test.go @@ -164,4 +164,4 @@ func TestGrantResponse_UserAndTeam(t *testing.T) { _, err := grantResponse(nil) require.ErrorIs(t, err, domain.ErrGrantNotFound()) }) -} \ No newline at end of file +} From 426a4f16adabb9dac7303301548738ab6640068a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 10:22:34 +0000 Subject: [PATCH 09/14] test(console): reset SDK config around every vitest Setup-file top-level _resetConfigForTesting() does not re-run for every file in a worker, so a spec that bound the DEV /api default still leaked into later MSW suites (ECONNREFUSED :3000). Reset before and after each test, and assert the write-once slot can rebind after reset. Co-authored-by: Silvan --- apps/console/src/api/api-base.spec.ts | 18 ++++++++++++++++++ apps/console/src/test-setup.ts | 13 +++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/console/src/api/api-base.spec.ts b/apps/console/src/api/api-base.spec.ts index 391290aa7..1f3e9047e 100644 --- a/apps/console/src/api/api-base.spec.ts +++ b/apps/console/src/api/api-base.spec.ts @@ -40,6 +40,24 @@ describe("apiBase", () => { expect(apiBase).toBe("/elsewhere"); }); + it("allows a later import to bind a new proxyPath after reset", async () => { + // The write-once globalThis slot is what leaked across Vitest files, not + // the `apiBase` export: a fresh module can print the stubbed env while + // `configureZitadel` still returns the previous `/api` handle. + vi.stubEnv("DEV", true); + vi.stubEnv("VITE_CONSOLE_API_BASE", undefined); + const first = await import("./zitadel"); + expect(first.apiBase).toBe("/api"); + + _resetConfigForTesting(); + vi.resetModules(); + vi.stubEnv("VITE_CONSOLE_API_BASE", "http://localhost/api"); + const second = await import("./zitadel"); + const { getZitadelConfig } = await import("@zitadel/api/config"); + expect(second.apiBase).toBe("http://localhost/api"); + expect(getZitadelConfig()?.proxyPath).toBe("http://localhost/api"); + }); + it("treats an empty VITE_CONSOLE_API_BASE as unset, matching the dev proxy", async () => { // The dev proxy's `env.VITE_CONSOLE_API_BASE || "/api"` treats "" as // unset; a `??` here would keep "" and bypass the proxy under the dev diff --git a/apps/console/src/test-setup.ts b/apps/console/src/test-setup.ts index f26f00b3d..37a674faf 100644 --- a/apps/console/src/test-setup.ts +++ b/apps/console/src/test-setup.ts @@ -1,12 +1,17 @@ +import { afterEach, beforeEach } from "vitest"; import { _resetConfigForTesting } from "@zitadel/api/config"; import "@testing-library/jest-dom/vitest"; // configureZitadel is write-once on globalThis so duplicate module copies -// share one slot. That slot also survives Vitest's per-file isolate, so -// whichever spec first calls configureZitadel keeps its proxyPath for every -// later file in the worker. CI then fetches `http://localhost:3000/api` -// (the DEV relative default) while handlers wait on `http://localhost/api`. +// share one slot. That slot also survives Vitest's per-file isolate, and +// this file's top-level body is not guaranteed to re-run for every spec in +// a worker. A file that bound the DEV `/api` default then leaked it into +// later files: CI fetched `http://localhost:3000/api` while MSW waited on +// `http://localhost/api`. Reset before and after each test so the next +// file's module init sees an empty slot even when setupFiles are cached. _resetConfigForTesting(); +beforeEach(_resetConfigForTesting); +afterEach(_resetConfigForTesting); // @ts-expect-error Needed for tests global.IS_REACT_ACT_ENVIRONMENT = true; From ce615c7d03ce6307048b03d04b39d27aad28a006 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 07:56:35 +0000 Subject: [PATCH 10/14] feat(api): accept identifier grant create without enumerating users POST /grants with user.identifier now returns 201 for miss, ambiguity, and duplicates so the status cannot tell whether the address exists. Self-grant from a session remains grant.invalid. user_id and team locators keep 404/409. Co-authored-by: Silvan --- .changeset/grant-api-locators.md | 2 +- api/generated/oas_client_gen.go | 10 + api/generated/oas_handlers_gen.go | 5 + api/generated/oas_schemas_gen.go | 7 +- api/generated/oas_server_gen.go | 5 + api/generated/oas_unimplemented_gen.go | 5 + api/openapi/endpoints/grants/methods.yaml | 19 +- .../endpoints/grants/user-locator.yaml | 7 +- internal/api/grant.go | 9 + internal/api/grant_internal_test.go | 24 +++ internal/api/integration_test/grant_test.go | 106 +++++++++- internal/service/grant.go | 187 ++++++++++++++---- internal/service/grant_test.go | 124 ++++++++++-- 13 files changed, 452 insertions(+), 58 deletions(-) diff --git a/.changeset/grant-api-locators.md b/.changeset/grant-api-locators.md index 87ba4f2b4..bd3b57071 100644 --- a/.changeset/grant-api-locators.md +++ b/.changeset/grant-api-locators.md @@ -2,4 +2,4 @@ "@zitadel/server": minor --- -Callers can create grants by user identifier or team name, not only by id. Create, get, and list name the bound person as `user` (`user_id`) or `team` (`team_id`) and drop `principal_type` / `principal_id`. `expand: ["principal"]` adds extra fields on that same object. +Callers can create grants by user identifier or team name, not only by id. Create, get, and list name the bound person as `user` (`user_id`) or `team` (`team_id`) and drop `principal_type` / `principal_id`. `expand: ["principal"]` adds extra fields on that same object. Creating by identifier always returns 201 except self-grant; it does not reveal whether the address matched. diff --git a/api/generated/oas_client_gen.go b/api/generated/oas_client_gen.go index ee58e614a..4a183d899 100644 --- a/api/generated/oas_client_gen.go +++ b/api/generated/oas_client_gen.go @@ -107,6 +107,11 @@ type Invoker interface { // unrevoked grant with the same principal and relation occupies the unique // key even after `expires_at`; DELETE it before re-creating. // Create does not accept `expand`; the 201 `user` / `team` are refs only. + // Creating by `user.identifier` is accepted with 201 whether or not + // a user matched: the server may write nothing, and a duplicate + // returns the existing grant. Granting the session caller's own + // resolved user is `grant.invalid`. Other locators still 404 / 409 + // when the principal is missing or the tuple already exists. // Accepts either a project secret (`oauth2`) or a user-bound Console // session cookie (`nextgenSession`). Session callers are authorized as // the human against the target project (home may differ). CSRF/Origin @@ -1500,6 +1505,11 @@ func (c *Client) sendCreateFlowDefinition(ctx context.Context, request *CreateFl // unrevoked grant with the same principal and relation occupies the unique // key even after `expires_at`; DELETE it before re-creating. // Create does not accept `expand`; the 201 `user` / `team` are refs only. +// Creating by `user.identifier` is accepted with 201 whether or not +// a user matched: the server may write nothing, and a duplicate +// returns the existing grant. Granting the session caller's own +// resolved user is `grant.invalid`. Other locators still 404 / 409 +// when the principal is missing or the tuple already exists. // Accepts either a project secret (`oauth2`) or a user-bound Console // session cookie (`nextgenSession`). Session callers are authorized as // the human against the target project (home may differ). CSRF/Origin diff --git a/api/generated/oas_handlers_gen.go b/api/generated/oas_handlers_gen.go index 972e5d7a8..b13258b6f 100644 --- a/api/generated/oas_handlers_gen.go +++ b/api/generated/oas_handlers_gen.go @@ -1199,6 +1199,11 @@ func (s *Server) handleCreateFlowDefinitionRequest(args [0]string, argsEscaped b // unrevoked grant with the same principal and relation occupies the unique // key even after `expires_at`; DELETE it before re-creating. // Create does not accept `expand`; the 201 `user` / `team` are refs only. +// Creating by `user.identifier` is accepted with 201 whether or not +// a user matched: the server may write nothing, and a duplicate +// returns the existing grant. Granting the session caller's own +// resolved user is `grant.invalid`. Other locators still 404 / 409 +// when the principal is missing or the tuple already exists. // Accepts either a project secret (`oauth2`) or a user-bound Console // session cookie (`nextgenSession`). Session callers are authorized as // the human against the target project (home may differ). CSRF/Origin diff --git a/api/generated/oas_schemas_gen.go b/api/generated/oas_schemas_gen.go index 6edb5ff07..77c1b75c9 100644 --- a/api/generated/oas_schemas_gen.go +++ b/api/generated/oas_schemas_gen.go @@ -52221,8 +52221,11 @@ type UserLocator struct { // Platform-homed user id (`user_`). The user must be active. UserID OptUserID `json:"user_id"` // The user schema's designated identifier (`x-identifier`), looked - // up in the platform project. Exactly one active match is required; - // zero or several resolve as not found. + // up in the platform project. Create accepts this locator with + // HTTP 201 whether or not a user matched: a miss or several matches + // still return a Grant and write nothing; a duplicate returns the + // existing grant. The server logs the lookup outcome. Granting the + // session caller's own resolved user is `grant.invalid`. Identifier OptString `json:"identifier"` } diff --git a/api/generated/oas_server_gen.go b/api/generated/oas_server_gen.go index e69c40bbe..b95c167d3 100644 --- a/api/generated/oas_server_gen.go +++ b/api/generated/oas_server_gen.go @@ -87,6 +87,11 @@ type Handler interface { // unrevoked grant with the same principal and relation occupies the unique // key even after `expires_at`; DELETE it before re-creating. // Create does not accept `expand`; the 201 `user` / `team` are refs only. + // Creating by `user.identifier` is accepted with 201 whether or not + // a user matched: the server may write nothing, and a duplicate + // returns the existing grant. Granting the session caller's own + // resolved user is `grant.invalid`. Other locators still 404 / 409 + // when the principal is missing or the tuple already exists. // Accepts either a project secret (`oauth2`) or a user-bound Console // session cookie (`nextgenSession`). Session callers are authorized as // the human against the target project (home may differ). CSRF/Origin diff --git a/api/generated/oas_unimplemented_gen.go b/api/generated/oas_unimplemented_gen.go index 1576e032f..cefa5400c 100644 --- a/api/generated/oas_unimplemented_gen.go +++ b/api/generated/oas_unimplemented_gen.go @@ -110,6 +110,11 @@ func (UnimplementedHandler) CreateFlowDefinition(ctx context.Context, req *Creat // unrevoked grant with the same principal and relation occupies the unique // key even after `expires_at`; DELETE it before re-creating. // Create does not accept `expand`; the 201 `user` / `team` are refs only. +// Creating by `user.identifier` is accepted with 201 whether or not +// a user matched: the server may write nothing, and a duplicate +// returns the existing grant. Granting the session caller's own +// resolved user is `grant.invalid`. Other locators still 404 / 409 +// when the principal is missing or the tuple already exists. // Accepts either a project secret (`oauth2`) or a user-bound Console // session cookie (`nextgenSession`). Session callers are authorized as // the human against the target project (home may differ). CSRF/Origin diff --git a/api/openapi/endpoints/grants/methods.yaml b/api/openapi/endpoints/grants/methods.yaml index ce0ca34dd..a9d81ee88 100644 --- a/api/openapi/endpoints/grants/methods.yaml +++ b/api/openapi/endpoints/grants/methods.yaml @@ -10,6 +10,11 @@ post: unrevoked grant with the same principal and relation occupies the unique key even after `expires_at`; DELETE it before re-creating. Create does not accept `expand`; the 201 `user` / `team` are refs only. + Creating by `user.identifier` is accepted with 201 whether or not + a user matched: the server may write nothing, and a duplicate + returns the existing grant. Granting the session caller's own + resolved user is `grant.invalid`. Other locators still 404 / 409 + when the principal is missing or the tuple already exists. Accepts either a project secret (`oauth2`) or a user-bound Console session cookie (`nextgenSession`). Session callers are authorized as @@ -31,7 +36,10 @@ post: $ref: create-grant-request.yaml responses: '201': - description: The grant was created successfully. + description: | + The request was accepted. Identifier create returns 201 whether + or not a grant row was written. Id and team-name locators + return 201 only when a grant was created. content: application/json: schema: @@ -55,13 +63,18 @@ post: schema: $ref: ../../components/error-details.yaml '404': - description: Project not found, or the principal could not be resolved. + description: | + Project not found, or an id or team-name locator could not be + resolved. Identifier create does not use 404 for a miss. content: application/json: schema: $ref: ../../components/error-details.yaml '409': - description: An unrevoked grant with this principal and relation already exists on the project. + description: | + An unrevoked grant with this principal and relation already + exists on the project. Identifier create returns 201 with the + existing grant instead. content: application/json: schema: diff --git a/api/openapi/endpoints/grants/user-locator.yaml b/api/openapi/endpoints/grants/user-locator.yaml index ac1754e1c..189c37262 100644 --- a/api/openapi/endpoints/grants/user-locator.yaml +++ b/api/openapi/endpoints/grants/user-locator.yaml @@ -15,6 +15,9 @@ properties: minLength: 1 description: | The user schema's designated identifier (`x-identifier`), looked - up in the platform project. Exactly one active match is required; - zero or several resolve as not found. + up in the platform project. Create accepts this locator with + HTTP 201 whether or not a user matched: a miss or several matches + still return a Grant and write nothing; a duplicate returns the + existing grant. The server logs the lookup outcome. Granting the + session caller's own resolved user is `grant.invalid`. example: alice@acme.com diff --git a/internal/api/grant.go b/internal/api/grant.go index 36c1ac4c7..d6f3dfeda 100644 --- a/internal/api/grant.go +++ b/internal/api/grant.go @@ -18,6 +18,7 @@ func (h *Handler) CreateGrant(ctx context.Context, req *api.CreateGrantRequest, if err != nil { return nil, err } + input.CallerUserID = grantCallerUserID(ctx) grant, err := h.grantService.Create(ctx, input) if err != nil { return nil, err @@ -25,6 +26,14 @@ func (h *Handler) CreateGrant(ctx context.Context, req *api.CreateGrantRequest, return grantResponse(grant) } +func grantCallerUserID(ctx context.Context) string { + scope, ok := GetScopeContext(ctx) + if !ok || scope.PrincipalType != domain.AuthzPrincipalTypeUser { + return "" + } + return scope.PrincipalID +} + func (h *Handler) GetGrant(ctx context.Context, params api.GetGrantParams) (api.GetGrantRes, error) { if err := h.requireProjectAccess(ctx, string(params.ProjectID), grantAccess, opRead); err != nil { return nil, err diff --git a/internal/api/grant_internal_test.go b/internal/api/grant_internal_test.go index 1f0f5a453..9ac1237f0 100644 --- a/internal/api/grant_internal_test.go +++ b/internal/api/grant_internal_test.go @@ -120,6 +120,30 @@ func TestCreateGrantInput_Locators(t *testing.T) { }) } +func TestGrantCallerUserID(t *testing.T) { + t.Parallel() + + t.Run("user session copies principal id", func(t *testing.T) { + ctx := WithScopeContext(t.Context(), ScopeContext{ + ProjectID: "proj_platform", + PrincipalType: domain.AuthzPrincipalTypeUser, + PrincipalID: "user_alice", + }) + assert.Equal(t, "user_alice", grantCallerUserID(ctx)) + }) + t.Run("project secret is empty", func(t *testing.T) { + ctx := WithScopeContext(t.Context(), ScopeContext{ + ProjectID: "proj_customer", + PrincipalType: domain.AuthzPrincipalTypeSKProj, + PrincipalID: "proj_customer", + }) + assert.Empty(t, grantCallerUserID(ctx)) + }) + t.Run("missing scope is empty", func(t *testing.T) { + assert.Empty(t, grantCallerUserID(t.Context())) + }) +} + func TestGrantResponse_UserAndTeam(t *testing.T) { asgn := &domain.AuthzAssignment{ ID: "asgn_1", diff --git a/internal/api/integration_test/grant_test.go b/internal/api/integration_test/grant_test.go index 0ec45d30f..599680d14 100644 --- a/internal/api/integration_test/grant_test.go +++ b/internal/api/integration_test/grant_test.go @@ -306,13 +306,117 @@ func TestGrantCreateLocators(t *testing.T) { assert.Equal(t, team.Name, created.Team.Value.Name.Or("")) }) - t.Run("unknown identifier is principal not found", func(t *testing.T) { + t.Run("unknown identifier is accepted without a row", func(t *testing.T) { t.Parallel() + before, err := client.QueryGrants(t.Context(), &api.QueryGrantsRequest{}, api.QueryGrantsParams{ProjectID: api.ProjectID(project.ID)}) + require.NoError(t, err) + listedBefore, ok := before.(*api.QueryGrantsResponse) + require.True(t, ok, helpers.MustMarshal(t, before)) + resp, err := client.CreateGrant(t.Context(), userIdentifierGrant("nobody@example.com", api.CreateGrantRequestRelationViewer), params) require.NoError(t, err) + created, ok := resp.(*api.Grant) + require.True(t, ok, helpers.MustMarshal(t, resp)) + assert.True(t, strings.HasPrefix(created.ID, "asgn_"), created.ID) + require.True(t, created.User.IsSet()) + assert.True(t, strings.HasPrefix(string(created.User.Value.UserID), "user_")) + assert.False(t, created.User.Value.Identifier.IsSet()) + + getResp, err := client.GetGrant(t.Context(), api.GetGrantParams{ + ID: created.ID, + ProjectID: api.ProjectID(project.ID), + }) + require.NoError(t, err) + assertGrantNotFound(t, getResp) + + after, err := client.QueryGrants(t.Context(), &api.QueryGrantsRequest{}, api.QueryGrantsParams{ProjectID: api.ProjectID(project.ID)}) + require.NoError(t, err) + listedAfter, ok := after.(*api.QueryGrantsResponse) + require.True(t, ok, helpers.MustMarshal(t, after)) + assert.Len(t, listedAfter.Grants, len(listedBefore.Grants)) + for _, g := range listedAfter.Grants { + assert.NotEqual(t, created.ID, g.ID) + } + }) + + t.Run("repeat identifier returns the existing grant", func(t *testing.T) { + t.Parallel() + userID := harness.CreateUserWithTeam(t, platform.ID) + userResp, err := platformClient.GetUserByID(t.Context(), api.GetUserByIDParams{UserID: api.UserID(userID)}) + require.NoError(t, err) + user, ok := userResp.(*api.User) + require.True(t, ok, helpers.MustMarshal(t, userResp)) + require.True(t, user.Identifier.IsSet()) + + req := userIdentifierGrant(user.Identifier.Value, api.CreateGrantRequestRelationEditor) + first, err := client.CreateGrant(t.Context(), req, params) + require.NoError(t, err) + created, ok := first.(*api.Grant) + require.True(t, ok, helpers.MustMarshal(t, first)) + + second, err := client.CreateGrant(t.Context(), req, params) + require.NoError(t, err) + again, ok := second.(*api.Grant) + require.True(t, ok, helpers.MustMarshal(t, second)) + assert.Equal(t, created.ID, again.ID) + assert.Equal(t, api.UserID(userID), again.User.Value.UserID) + + listed, err := client.QueryGrants(t.Context(), &api.QueryGrantsRequest{ + Filter: []api.QueryGrantsRequestFilterItem{{ + Field: api.GrantFilterFieldUserID, + Operation: api.FilterOperationEquals, + Value: api.NewOptFilterValue(api.NewStringFilterValue(userID)), + }}, + }, api.QueryGrantsParams{ProjectID: api.ProjectID(project.ID)}) + require.NoError(t, err) + page, ok := listed.(*api.QueryGrantsResponse) + require.True(t, ok, helpers.MustMarshal(t, listed)) + require.Len(t, page.Grants, 1) + assert.Equal(t, created.ID, page.Grants[0].ID) + }) + + t.Run("own identifier is grant.invalid", func(t *testing.T) { + t.Parallel() + userID := harness.CreateUserWithTeam(t, platform.ID) + harness.SeedProjectViewer(t, project.ID, userID) + userResp, err := platformClient.GetUserByID(t.Context(), api.GetUserByIDParams{UserID: api.UserID(userID)}) + require.NoError(t, err) + user, ok := userResp.(*api.User) + require.True(t, ok, helpers.MustMarshal(t, userResp)) + require.True(t, user.Identifier.IsSet()) + + sessionClient, err := helpers.NewApiClient(harness.EnsureTestServer(t).URL) + require.NoError(t, err) + sessionClient.SetSessionToken(platformSessionCookie(t, userID).Value) + + resp, err := sessionClient.CreateGrant(t.Context(), userIdentifierGrant(user.Identifier.Value, api.CreateGrantRequestRelationAdmin), params) + require.NoError(t, err) + bad, ok := resp.(*api.CreateGrantBadRequest) + require.True(t, ok, helpers.MustMarshal(t, resp)) + assert.Equal(t, api.ErrorCode("grant.invalid"), bad.Code) + assert.Equal(t, "you cannot grant access to yourself", bad.Message) + }) + + t.Run("unknown user_id is principal not found", func(t *testing.T) { + t.Parallel() + resp, err := client.CreateGrant(t.Context(), userIDGrant("user_01hzzzzzzzzzzzzzzzzzzzzzzz", api.CreateGrantRequestRelationViewer), params) + require.NoError(t, err) assertGrantPrincipalNotFound(t, resp) }) + t.Run("duplicate user_id is already exists", func(t *testing.T) { + t.Parallel() + userID := harness.CreateUserWithTeam(t, platform.ID) + req := userIDGrant(userID, api.CreateGrantRequestRelationViewer) + first, err := client.CreateGrant(t.Context(), req, params) + require.NoError(t, err) + require.IsType(t, &api.Grant{}, first, helpers.MustMarshal(t, first)) + + second, err := client.CreateGrant(t.Context(), req, params) + require.NoError(t, err) + assertGrantAlreadyExists(t, second) + }) + t.Run("both user and team is invalid", func(t *testing.T) { t.Parallel() resp, err := client.CreateGrant(t.Context(), &api.CreateGrantRequest{ diff --git a/internal/service/grant.go b/internal/service/grant.go index bcabf1766..dd1a68cc7 100644 --- a/internal/service/grant.go +++ b/internal/service/grant.go @@ -58,66 +58,134 @@ func NewGrantService(v2Pool *DB, refs UserRefResolver, platformProjectID string) } type CreateGrantInput struct { - ProjectID string - Relation string - ExpiresAt *time.Time - UserID string - Identifier string - TeamID string - TeamName string + ProjectID string + Relation string + ExpiresAt *time.Time + UserID string + Identifier string + TeamID string + TeamName string + CallerUserID string } +// errIdentifierUnresolved is the identifier-path miss/ambiguity signal. +// Create maps it to a synthetic 201 rather than grant.principal_not_found +// so the HTTP status cannot tell a caller whether the address matched. +var errIdentifierUnresolved = errors.New("grant identifier unresolved") + func (s *GrantService) Create(ctx context.Context, input CreateGrantInput) (*Grant, error) { input.UserID = strings.TrimSpace(input.UserID) input.Identifier = strings.TrimSpace(input.Identifier) input.TeamID = strings.TrimSpace(input.TeamID) input.TeamName = strings.TrimSpace(input.TeamName) + input.CallerUserID = strings.TrimSpace(input.CallerUserID) if err := validateCreateGrant(input); err != nil { return nil, err } + if input.Identifier != "" { + return s.createByIdentifier(ctx, input) + } + return s.createByResolvedLocator(ctx, input) +} + +func (s *GrantService) createByIdentifier(ctx context.Context, input CreateGrantInput) (*Grant, error) { + userID, err := s.resolveUserByIdentifier(ctx, s.v2Pool.Statements(), s.locatorHome(input.ProjectID), input.Identifier) + if err != nil { + if errors.Is(err, errIdentifierUnresolved) { + return s.syntheticIdentifierGrant(ctx, input) + } + if de, ok := errors.AsType[domain.Error](err); ok { + return nil, de + } + return nil, domain.ErrInternal(err).WithMessage("failed to create grant") + } + if input.CallerUserID != "" && input.CallerUserID == userID { + return nil, domain.ErrGrantInvalid().WithMessage("you cannot grant access to yourself") + } + grant, err := s.commitGrant(ctx, input, domain.AuthzPrincipalTypeUser, userID) + if err != nil { + if errors.Is(err, domain.ErrGrantAlreadyExists()) { + return s.existingOrSyntheticGrant(ctx, input, userID) + } + return nil, err + } + return grant, nil +} +func (s *GrantService) createByResolvedLocator(ctx context.Context, input CreateGrantInput) (*Grant, error) { var created *domain.AuthzAssignment err := s.v2Pool.Transaction(ctx, func(ctx context.Context, tx Statementer[AllStatements]) error { principalType, principalID, err := s.resolveLocator(ctx, tx.Statements(), input) if err != nil { return err } - - asgn := &domain.AuthzAssignment{ - ProjectID: input.ProjectID, - CatalogID: domain.SystemCatalogID, - PrincipalType: principalType, - PrincipalID: principalID, - ObjectType: "project", - Relation: input.Relation, - ExpiresAt: input.ExpiresAt, - } - asgn.ApplyScope(domain.NewProjectAssignmentScope()) - if err := tx.Statements().CreateAuthzAssignment(ctx, asgn); err != nil { + asgn, err := s.writeGrant(ctx, tx.Statements(), input, principalType, principalID) + if err != nil { return err } - if err := emitManagedGrant(ctx, tx.Statements(), domain.EventTypeAuthzGranted, asgn, domain.AuthzGrantedPayload{ - PrincipalType: asgn.PrincipalType.String(), - PrincipalID: asgn.PrincipalID, - Relation: asgn.Relation, - }); err != nil { + created = asgn + return nil + }) + if err != nil { + return nil, mapGrantWriteError(err) + } + return s.hydrateCreated(ctx, created) +} + +func (s *GrantService) commitGrant(ctx context.Context, input CreateGrantInput, principalType domain.AuthzPrincipalType, principalID string) (*Grant, error) { + var created *domain.AuthzAssignment + err := s.v2Pool.Transaction(ctx, func(ctx context.Context, tx Statementer[AllStatements]) error { + asgn, err := s.writeGrant(ctx, tx.Statements(), input, principalType, principalID) + if err != nil { return err } created = asgn return nil }) if err != nil { - if _, ok := errors.AsType[*database.UniqueError](err); ok { - return nil, domain.ErrGrantAlreadyExists().WithParent(err) - } - if _, ok := errors.AsType[*database.ForeignKeyError](err); ok { - return nil, domain.ErrGrantInvalid().WithParent(err) - } - if de, ok := errors.AsType[domain.Error](err); ok { - return nil, de - } - return nil, domain.ErrInternal(err).WithMessage("failed to create grant") + return nil, mapGrantWriteError(err) + } + return s.hydrateCreated(ctx, created) +} + +func (s *GrantService) writeGrant(ctx context.Context, stmts AllStatements, input CreateGrantInput, principalType domain.AuthzPrincipalType, principalID string) (*domain.AuthzAssignment, error) { + asgn := &domain.AuthzAssignment{ + ProjectID: input.ProjectID, + CatalogID: domain.SystemCatalogID, + PrincipalType: principalType, + PrincipalID: principalID, + ObjectType: "project", + Relation: input.Relation, + ExpiresAt: input.ExpiresAt, + } + asgn.ApplyScope(domain.NewProjectAssignmentScope()) + if err := stmts.CreateAuthzAssignment(ctx, asgn); err != nil { + return nil, err } + if err := emitManagedGrant(ctx, stmts, domain.EventTypeAuthzGranted, asgn, domain.AuthzGrantedPayload{ + PrincipalType: asgn.PrincipalType.String(), + PrincipalID: asgn.PrincipalID, + Relation: asgn.Relation, + }); err != nil { + return nil, err + } + return asgn, nil +} + +func mapGrantWriteError(err error) error { + if _, ok := errors.AsType[*database.UniqueError](err); ok { + return domain.ErrGrantAlreadyExists().WithParent(err) + } + if _, ok := errors.AsType[*database.ForeignKeyError](err); ok { + return domain.ErrGrantInvalid().WithParent(err) + } + if de, ok := errors.AsType[domain.Error](err); ok { + return de + } + return domain.ErrInternal(err).WithMessage("failed to create grant") +} + +func (s *GrantService) hydrateCreated(ctx context.Context, created *domain.AuthzAssignment) (*Grant, error) { grant, err := s.hydrate(ctx, false, created) if err != nil { // The assignment has already committed: a ref/team load failure must @@ -128,6 +196,49 @@ func (s *GrantService) Create(ctx context.Context, input CreateGrantInput) (*Gra return grant[0], nil } +func (s *GrantService) syntheticIdentifierGrant(ctx context.Context, input CreateGrantInput) (*Grant, error) { + stmts := s.v2Pool.Statements() + asgnID, err := stmts.NewManagedID(string(domain.PrefixAuthzAssignment)) + if err != nil { + return nil, domain.ErrInternal(err).WithMessage("failed to create grant") + } + userID, err := stmts.NewManagedID(string(domain.PrefixUser)) + if err != nil { + return nil, domain.ErrInternal(err).WithMessage("failed to create grant") + } + now := time.Now() + asgn := &domain.AuthzAssignment{ + ID: asgnID, + ProjectID: input.ProjectID, + CatalogID: domain.SystemCatalogID, + PrincipalType: domain.AuthzPrincipalTypeUser, + PrincipalID: userID, + ObjectType: "project", + Relation: input.Relation, + ExpiresAt: input.ExpiresAt, + CreatedAt: now, + UpdatedAt: now, + } + asgn.ApplyScope(domain.NewProjectAssignmentScope()) + return idOnlyGrant(asgn), nil +} + +func (s *GrantService) existingOrSyntheticGrant(ctx context.Context, input CreateGrantInput, userID string) (*Grant, error) { + asgns, err := s.v2Pool.Statements().ListAuthzAssignments(ctx, input.ProjectID, domain.AuthzPrincipalTypeUser, userID, false) + if err != nil { + return nil, domain.ErrInternal(err).WithMessage("failed to create grant") + } + for _, asgn := range asgns { + if asgn.Relation == input.Relation && isManagedGrant(asgn) { + return s.hydrateCreated(ctx, asgn) + } + } + getLoggingContext(ctx, "grant").Info("grant identifier unique conflict without a matching row", + slog.String("project_id", input.ProjectID), + ) + return s.syntheticIdentifierGrant(ctx, input) +} + func (s *GrantService) Get(ctx context.Context, projectID, id string, includePrincipal bool) (*Grant, error) { asgn, err := s.v2Pool.Statements().GetAuthzAssignment(ctx, projectID, id) if err != nil { @@ -277,7 +388,7 @@ func (s *GrantService) resolveUserByIdentifier(ctx context.Context, stmts AllSta slog.String("home_project_id", home), slog.String("identifier_property", key), ) - return "", domain.ErrGrantPrincipalNotFound() + return "", errIdentifierUnresolved } return "", err } @@ -290,8 +401,12 @@ func (s *GrantService) resolveUserByIdentifier(ctx context.Context, stmts AllSta slog.String("home_project_id", home), slog.Int("matches", len(found)), ) + } else { + getLoggingContext(ctx, "grant").Info("grant identifier lookup matched no user", + slog.String("home_project_id", home), + ) } - return "", domain.ErrGrantPrincipalNotFound() + return "", errIdentifierUnresolved } return matchID, nil } diff --git a/internal/service/grant_test.go b/internal/service/grant_test.go index d2b1a48ee..9d8455c43 100644 --- a/internal/service/grant_test.go +++ b/internal/service/grant_test.go @@ -315,30 +315,81 @@ func TestGrantService_CreateLocators(t *testing.T) { assert.Equal(t, userID, got.Assignment.PrincipalID) }) - t.Run("identifier miss is not found", func(t *testing.T) { + t.Run("identifier duplicate returns the existing grant", func(t *testing.T) { t.Parallel() + existing := testManagedGrant("asgn_existing", userID) svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { - s.EXPECT().ListJSONSchemas(gomock.Any(), userSchemaListFilter(), gomock.Any()).Return(&database.ListResult[*domain.JSONSchema]{ - Items: []*domain.JSONSchema{{ - ProjectID: grantPlatformProjID, - URL: "https://s/human", - Kind: domain.JSONSchemaKindUserSchema, - Schema: []byte(schemaDoc), - }}, + expectIdentifierSchema(s, schemaDoc) + s.EXPECT().GetUser(gomock.Any(), userLocatorFilter(true), gomock.Any()).Return(&domain.User{ + ProjectID: grantPlatformProjID, + ID: userID, + Metadata: domain.UserMetadata{Status: domain.UserStatusActive}, }, nil) + s.EXPECT().CreateAuthzAssignment(gomock.Any(), gomock.Any()). + Return(database.NewUniqueError("authz_assignments", "authz_assignments_unique_active", nil)) + s.EXPECT().ListAuthzAssignments(gomock.Any(), "proj_customer", domain.AuthzPrincipalTypeUser, userID, false). + Return([]*domain.AuthzAssignment{existing}, nil) + }) + got, err := svc.Create(t.Context(), service.CreateGrantInput{ + ProjectID: "proj_customer", + Identifier: "alice@acme.com", + Relation: "viewer", + }) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "asgn_existing", got.Assignment.ID) + assert.Equal(t, userID, got.Assignment.PrincipalID) + }) + + t.Run("identifier self-grant is invalid", func(t *testing.T) { + t.Parallel() + svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { + expectIdentifierSchema(s, schemaDoc) + s.EXPECT().GetUser(gomock.Any(), userLocatorFilter(true), gomock.Any()).Return(&domain.User{ + ProjectID: grantPlatformProjID, + ID: userID, + Metadata: domain.UserMetadata{Status: domain.UserStatusActive}, + }, nil) + }) + got, err := svc.Create(t.Context(), service.CreateGrantInput{ + ProjectID: "proj_customer", + Identifier: "alice@acme.com", + Relation: "admin", + CallerUserID: userID, + }) + require.ErrorIs(t, err, domain.ErrGrantInvalid()) + assert.Nil(t, got) + var de domain.Error + require.ErrorAs(t, err, &de) + assert.Equal(t, "you cannot grant access to yourself", de.Message) + }) + + t.Run("identifier miss is accepted without a write", func(t *testing.T) { + t.Parallel() + svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { + expectIdentifierSchema(s, schemaDoc) s.EXPECT().GetUser(gomock.Any(), userLocatorFilter(true), gomock.Any()). Return(nil, database.NewNoRowFoundError(nil)) + s.EXPECT().NewManagedID(string(domain.PrefixAuthzAssignment)).Return("asgn_neutral", nil) + s.EXPECT().NewManagedID(string(domain.PrefixUser)).Return("user_neutral", nil) }) got, err := svc.Create(t.Context(), service.CreateGrantInput{ ProjectID: "proj_customer", Identifier: "missing@acme.com", Relation: "viewer", }) - require.ErrorIs(t, err, domain.ErrGrantPrincipalNotFound()) - assert.Nil(t, got) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "asgn_neutral", got.Assignment.ID) + assert.Equal(t, "proj_customer", got.Assignment.ProjectID) + assert.Equal(t, "viewer", got.Assignment.Relation) + require.NotNil(t, got.User) + assert.Equal(t, "user_neutral", got.User.UserID) + assert.Empty(t, got.User.Identifier) + assert.Nil(t, got.Team) }) - t.Run("ambiguous identifier is not found", func(t *testing.T) { + t.Run("ambiguous identifier is accepted without a write", func(t *testing.T) { t.Parallel() svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { s.EXPECT().ListJSONSchemas(gomock.Any(), userSchemaListFilter(), gomock.Any()).Return(&database.ListResult[*domain.JSONSchema]{ @@ -363,14 +414,19 @@ func TestGrantService_CreateLocators(t *testing.T) { s.EXPECT().GetUser(gomock.Any(), userLocatorFilter(true), gomock.Any()).Return(&domain.User{ ID: "user_username_match", Metadata: domain.UserMetadata{Status: domain.UserStatusActive}, }, nil) + s.EXPECT().NewManagedID(string(domain.PrefixAuthzAssignment)).Return("asgn_ambiguous", nil) + s.EXPECT().NewManagedID(string(domain.PrefixUser)).Return("user_ambiguous", nil) }) got, err := svc.Create(t.Context(), service.CreateGrantInput{ ProjectID: "proj_customer", Identifier: "alice", Relation: "viewer", }) - require.ErrorIs(t, err, domain.ErrGrantPrincipalNotFound()) - assert.Nil(t, got) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "asgn_ambiguous", got.Assignment.ID) + require.NotNil(t, got.User) + assert.Equal(t, "user_ambiguous", got.User.UserID) }) t.Run("identifier skips unique value on undesignated schema", func(t *testing.T) { @@ -546,6 +602,37 @@ func TestGrantService_CreateLocators(t *testing.T) { require.ErrorIs(t, err, domain.ErrGrantInvalid()) assert.Nil(t, got) }) + + t.Run("user_id miss is not found", func(t *testing.T) { + t.Parallel() + svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { + s.EXPECT().GetResourceScope(gomock.Any(), userID). + Return(nil, database.NewNoRowFoundError(nil)) + }) + got, err := svc.Create(t.Context(), service.CreateGrantInput{ + ProjectID: "proj_customer", + UserID: userID, + Relation: "viewer", + }) + require.ErrorIs(t, err, domain.ErrGrantPrincipalNotFound()) + assert.Nil(t, got) + }) + + t.Run("user_id unique is already exists", func(t *testing.T) { + t.Parallel() + svc := newMockedGrantService(t, grantPlatformProjID, func(s *servicemocks.MockAllStatements) { + expectActiveUserPrincipal(s, userID) + s.EXPECT().CreateAuthzAssignment(gomock.Any(), gomock.Any()). + Return(database.NewUniqueError("authz_assignments", "authz_assignments_unique_active", nil)) + }) + got, err := svc.Create(t.Context(), service.CreateGrantInput{ + ProjectID: "proj_customer", + UserID: userID, + Relation: "viewer", + }) + require.ErrorIs(t, err, domain.ErrGrantAlreadyExists()) + assert.Nil(t, got) + }) } func TestGrantService_Get(t *testing.T) { @@ -853,6 +940,17 @@ func expectHydrateTeam(s *servicemocks.MockAllStatements, teamID string) { }, nil) } +func expectIdentifierSchema(s *servicemocks.MockAllStatements, schemaDoc string) { + s.EXPECT().ListJSONSchemas(gomock.Any(), userSchemaListFilter(), gomock.Any()).Return(&database.ListResult[*domain.JSONSchema]{ + Items: []*domain.JSONSchema{{ + ProjectID: grantPlatformProjID, + URL: "https://s/human", + Kind: domain.JSONSchemaKindUserSchema, + Schema: []byte(schemaDoc), + }}, + }, nil) +} + type grantRefStub struct { byID map[string]domain.UserRef err error From a65328db40eddd7d7e632b34be2c5cf4d755c817 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 11:52:02 +0000 Subject: [PATCH 11/14] test(api): isolate unknown-identifier grant list assertion Give the identifier-miss create subtest its own project so a global QueryGrants length check cannot race sibling t.Parallel() creates. Co-authored-by: Silvan --- internal/api/integration_test/grant_test.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/internal/api/integration_test/grant_test.go b/internal/api/integration_test/grant_test.go index 599680d14..d23aa8988 100644 --- a/internal/api/integration_test/grant_test.go +++ b/internal/api/integration_test/grant_test.go @@ -308,12 +308,21 @@ func TestGrantCreateLocators(t *testing.T) { t.Run("unknown identifier is accepted without a row", func(t *testing.T) { t.Parallel() - before, err := client.QueryGrants(t.Context(), &api.QueryGrantsRequest{}, api.QueryGrantsParams{ProjectID: api.ProjectID(project.ID)}) + // Own project: sibling t.Parallel() creates on the shared project + // would otherwise make a global QueryGrants length assertion race. + isolated, err := harness.EnsureProjectService(t).Create(t.Context(), helpers.ProjectName(), nil, true) + require.NoError(t, err) + isolatedClient, err := helpers.NewApiClient(harness.EnsureTestServer(t).URL) + require.NoError(t, err) + harness.SetProjectSecretOnApiClient(t, isolatedClient, isolated) + isolatedParams := api.CreateGrantParams{ProjectID: api.ProjectID(isolated.ID)} + + before, err := isolatedClient.QueryGrants(t.Context(), &api.QueryGrantsRequest{}, api.QueryGrantsParams{ProjectID: api.ProjectID(isolated.ID)}) require.NoError(t, err) listedBefore, ok := before.(*api.QueryGrantsResponse) require.True(t, ok, helpers.MustMarshal(t, before)) - resp, err := client.CreateGrant(t.Context(), userIdentifierGrant("nobody@example.com", api.CreateGrantRequestRelationViewer), params) + resp, err := isolatedClient.CreateGrant(t.Context(), userIdentifierGrant("nobody@example.com", api.CreateGrantRequestRelationViewer), isolatedParams) require.NoError(t, err) created, ok := resp.(*api.Grant) require.True(t, ok, helpers.MustMarshal(t, resp)) @@ -322,14 +331,14 @@ func TestGrantCreateLocators(t *testing.T) { assert.True(t, strings.HasPrefix(string(created.User.Value.UserID), "user_")) assert.False(t, created.User.Value.Identifier.IsSet()) - getResp, err := client.GetGrant(t.Context(), api.GetGrantParams{ + getResp, err := isolatedClient.GetGrant(t.Context(), api.GetGrantParams{ ID: created.ID, - ProjectID: api.ProjectID(project.ID), + ProjectID: api.ProjectID(isolated.ID), }) require.NoError(t, err) assertGrantNotFound(t, getResp) - after, err := client.QueryGrants(t.Context(), &api.QueryGrantsRequest{}, api.QueryGrantsParams{ProjectID: api.ProjectID(project.ID)}) + after, err := isolatedClient.QueryGrants(t.Context(), &api.QueryGrantsRequest{}, api.QueryGrantsParams{ProjectID: api.ProjectID(isolated.ID)}) require.NoError(t, err) listedAfter, ok := after.(*api.QueryGrantsResponse) require.True(t, ok, helpers.MustMarshal(t, after)) From ee97f37d706a5bc9a7fd8f3fa046aa612032039d Mon Sep 17 00:00:00 2001 From: Marco Ardizzone Date: Mon, 21 Sep 2026 11:50:52 +0200 Subject: [PATCH 12/14] ci(server): raise spanner-emulator test timeout to 20m (#1011) The spanner-emulator integration lane intermittently trips its 10m per-binary timeout on TestListAuthzTeamScopedOnlyPartialView. The emulator's query planner is pathologically slow on the principal-scoped authz list predicate, spiking to ~9m30s, while real Cloud Spanner runs the same predicate in ~40ms and the postgres lane passes the same test in 0.08s. This is the known emulator-planner pathology tracked in #1011, not a regression in this branch. Bump the timeout to 20m for the emulator lane only; the other lanes stay tight so genuine hangs still surface. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016GVV2xsPNwqKzQY1xVZu73 --- apps/server/moon.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/server/moon.yml b/apps/server/moon.yml index b00e085f2..94a0331cc 100644 --- a/apps/server/moon.yml +++ b/apps/server/moon.yml @@ -241,8 +241,13 @@ tasks: - "-count=1" - "-tags" - "spanner_integration" + # Emulator-only budget: the emulator's query planner is pathologically slow + # on the principal-scoped authz list predicate (TestListAuthzTeamScoped*), + # spiking to ~9m30s and tripping a 10m timeout, while real Cloud Spanner + # runs the same predicate in ~40ms. Postgres runs the same test in 0.08s. + # Keep the other lanes tight so genuine hangs still surface; see #1011. - "-timeout" - - "10m" + - "20m" - "-p" - "1" - "./..." From e5105ee77517df2da0271d2e29bc7fd4c8aeeea1 Mon Sep 17 00:00:00 2001 From: Marco Ardizzone Date: Mon, 21 Sep 2026 12:15:01 +0200 Subject: [PATCH 13/14] fix(console): send the new grant locator shape from real-instance helpers The e2e grantProjectAdmin and dev-real grantDevUserAdmin helpers still sent the old { principal_type, principal_id } body to POST /grants, which the new locator contract rejects with grant.invalid ("exactly one of user or team is required"). Send { user: { user_id }, relation } instead. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016GVV2xsPNwqKzQY1xVZu73 --- apps/console-e2e/src-real/support.ts | 2 +- apps/console/scripts/dev-real.mts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/console-e2e/src-real/support.ts b/apps/console-e2e/src-real/support.ts index 2039d65d0..5232a6806 100644 --- a/apps/console-e2e/src-real/support.ts +++ b/apps/console-e2e/src-real/support.ts @@ -60,7 +60,7 @@ export async function grantProjectAdmin( authorization: `Bearer ${handle.projectSecret}`, "content-type": "application/json", }, - body: JSON.stringify({ principal_type: "user", principal_id: userId, relation: "admin" }), + body: JSON.stringify({ user: { user_id: userId }, relation: "admin" }), }); if (!response.ok) { throw new Error(`POST /grants answered ${response.status}: ${await response.text()}`); diff --git a/apps/console/scripts/dev-real.mts b/apps/console/scripts/dev-real.mts index a4f1c70f0..edab3e2e0 100644 --- a/apps/console/scripts/dev-real.mts +++ b/apps/console/scripts/dev-real.mts @@ -191,7 +191,7 @@ async function grantDevUserAdmin(userId: string): Promise { const response = await fetch(`${baseUrl}/grants?${query.toString()}`, { method: "POST", headers: { authorization: `Bearer ${projectSecret}`, "content-type": "application/json" }, - body: JSON.stringify({ principal_type: "user", principal_id: userId, relation: "admin" }), + body: JSON.stringify({ user: { user_id: userId }, relation: "admin" }), signal: AbortSignal.timeout(5_000), }); return response.ok; From cc9c09b206539b299215d9b5326aac48542f2e7a Mon Sep 17 00:00:00 2001 From: Marco Ardizzone Date: Mon, 21 Sep 2026 13:48:11 +0200 Subject: [PATCH 14/14] fix(cli): reconcile grants resource commands with the user/team locator shape PR #1210 auto-generates `zitadel grants ...` CLI commands from the OpenAPI spec and shipped tests against the old grant shape. This PR (#1192) changed the grant contract to a user/team locator, so the two collided and cli:test failed. Reconcile the CLI to the new contract: - list filters: drop principal_type/principal_id (the server no longer accepts them) and send user_id/team_id instead. - columns/detail: read the nested response via dotted paths user.user_id and team.team_id (a grant is user XOR team). - create: the locator (user/team) is a nested object, so it goes through --data; only the scalar relation/expires_at derive flags. This is the CRUD framework's intended behavior, left unchanged. - tests: update the three failing unit files to the new schema and the framework's actual derived flags. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016GVV2xsPNwqKzQY1xVZu73 --- apps/cli/src/commands/resources.ts | 10 +++--- apps/cli/src/lib/oclif/crud/fields.ts | 4 +-- .../unit/commands/resources-list.test.ts | 8 ----- .../cli/tests/unit/commands/resources.test.ts | 31 ++++++++--------- .../tests/unit/lib/oclif/crud/fields.test.ts | 33 +++++++++---------- 5 files changed, 39 insertions(+), 47 deletions(-) diff --git a/apps/cli/src/commands/resources.ts b/apps/cli/src/commands/resources.ts index 629f7d195..2b2390eb5 100644 --- a/apps/cli/src/commands/resources.ts +++ b/apps/cli/src/commands/resources.ts @@ -267,11 +267,11 @@ export const RESOURCES = { group: CommandGroups.resources, singular: "grant", idField: "id", - columns: ["id", "principal_type", "principal_id", "relation", "created_at", "expires_at"], + columns: ["id", "user.user_id", "team.team_id", "relation", "created_at", "expires_at"], heading: "id", detail: [ - "principal_type", - "principal_id", + "user.user_id", + "team.team_id", "relation", "object_type", "created_at", @@ -283,8 +283,8 @@ export const RESOURCES = { response: QueryGrantsResponse, filters: [ { field: "created_at", operations: FILTER_OPERATIONS }, - { field: "principal_type", operations: FILTER_OPERATIONS }, - { field: "principal_id", operations: FILTER_OPERATIONS }, + { field: "user_id", operations: FILTER_OPERATIONS }, + { field: "team_id", operations: FILTER_OPERATIONS }, { field: "relation", operations: FILTER_OPERATIONS }, { field: "expires_at", operations: FILTER_OPERATIONS }, ], diff --git a/apps/cli/src/lib/oclif/crud/fields.ts b/apps/cli/src/lib/oclif/crud/fields.ts index c13964eef..d616f4cff 100644 --- a/apps/cli/src/lib/oclif/crud/fields.ts +++ b/apps/cli/src/lib/oclif/crud/fields.ts @@ -23,9 +23,9 @@ import { unwrap, type ZodLike } from "./zod"; export type FieldKind = "string" | "number" | "boolean" | "enum" | "record"; export type BodyField = Readonly<{ - /** Property name on the wire (`principal_type`). */ + /** Property name on the wire (`relation`). */ name: string; - /** Flag name as typed (`principal-type`). */ + /** Flag name as typed (`relation`). */ flag: string; kind: FieldKind; required: boolean; diff --git a/apps/cli/tests/unit/commands/resources-list.test.ts b/apps/cli/tests/unit/commands/resources-list.test.ts index 077742c74..7cb686783 100644 --- a/apps/cli/tests/unit/commands/resources-list.test.ts +++ b/apps/cli/tests/unit/commands/resources-list.test.ts @@ -47,14 +47,6 @@ describe("zitadel resources", () => { const grants = resources.find((resource) => resource.topic === "grants"); expect(grants?.create_fields).toEqual([ - { - name: "principal_type", - flag: "--principal-type", - kind: "enum", - required: true, - options: ["user", "team"], - }, - { name: "principal_id", flag: "--principal-id", kind: "string", required: true }, { name: "relation", flag: "--relation", diff --git a/apps/cli/tests/unit/commands/resources.test.ts b/apps/cli/tests/unit/commands/resources.test.ts index 122145311..6b09c7314 100644 --- a/apps/cli/tests/unit/commands/resources.test.ts +++ b/apps/cli/tests/unit/commands/resources.test.ts @@ -835,12 +835,10 @@ describe("body field flags", () => { const res = await run(cwd, [ "grants", "create", - "--principal-type", - "robot", - "--principal-id", - "user_1", "--relation", - "viewer", + "robot", + "--data", + JSON.stringify({ user: { user_id: "user_1" } }), ]); expect(res.exitCode).toBe(3); const json = parseJson(res.stdout) as { code: string; message: string }; @@ -850,7 +848,14 @@ describe("body field flags", () => { it("names the required flags that are missing", async () => { const cwd = await makeProject(); - const res = await run(cwd, ["grants", "create", "--principal-id", "user_1"]); + // The locator (`user`/`team`) is a nested object, so it rides in --data; + // `relation` is the only required scalar flag the framework knows about. + const res = await run(cwd, [ + "grants", + "create", + "--data", + JSON.stringify({ user: { user_id: "user_1" } }), + ]); expect(res.exitCode).toBe(3); const json = parseJson(res.stdout) as { code: string; @@ -859,11 +864,9 @@ describe("body field flags", () => { details: { missing: string[] }; }; expect(json.code).toBe("E_VALIDATION"); - expect(json.message).toBe( - "grants create is missing required fields: --principal-type, --relation", - ); - expect(json.hint).toContain("--principal-type and --relation"); - expect(json.details.missing).toEqual(["principal_type", "relation"]); + expect(json.message).toBe("grants create is missing required field: --relation"); + expect(json.hint).toContain("--relation"); + expect(json.details.missing).toEqual(["relation"]); }); it("accepts a required field supplied through --data instead of its flag", async () => { @@ -880,12 +883,10 @@ describe("body field flags", () => { "grants", "create", "--data", - JSON.stringify({ principal_type: "user", relation: "viewer" }), - "--principal-id", - "user_1", + JSON.stringify({ user: { user_id: "user_1" }, relation: "viewer" }), ]); expect(res.exitCode).toBe(0); - expect(body).toEqual({ principal_type: "user", principal_id: "user_1", relation: "viewer" }); + expect(body).toEqual({ user: { user_id: "user_1" }, relation: "viewer" }); }); it("reports a single missing field in the singular", async () => { diff --git a/apps/cli/tests/unit/lib/oclif/crud/fields.test.ts b/apps/cli/tests/unit/lib/oclif/crud/fields.test.ts index 271dcdc99..44a0b71cb 100644 --- a/apps/cli/tests/unit/lib/oclif/crud/fields.test.ts +++ b/apps/cli/tests/unit/lib/oclif/crud/fields.test.ts @@ -20,10 +20,11 @@ const byName = (schema: Parameters[0]) => describe("describeBody", () => { it("marks required and optional fields from the schema", () => { const fields = byName(CreateGrantBody); - expect(fields.principal_type?.required).toBe(true); - expect(fields.principal_id?.required).toBe(true); expect(fields.relation?.required).toBe(true); expect(fields.expires_at?.required).toBe(false); + // `user` and `team` are nested objects: no flag, reached through --data. + expect(fields.user).toBeUndefined(); + expect(fields.team).toBeUndefined(); }); it("treats a fully optional body as optional", () => { @@ -32,15 +33,16 @@ describe("describeBody", () => { }); it("carries enum options and kebab-cases the flag name", () => { - const field = byName(CreateGrantBody).principal_type; - expect(field?.flag).toBe("principal-type"); - expect(field?.kind).toBe("enum"); - expect(field?.options).toEqual(["user", "team"]); + const fields = byName(CreateGrantBody); + expect(fields.relation?.kind).toBe("enum"); + expect(fields.relation?.options).toEqual(["viewer", "editor", "admin"]); + // A `_` in the wire name becomes a `-` in the flag. + expect(fields.expires_at?.flag).toBe("expires-at"); }); it("summarises a long multi-line description to one sentence", () => { - const summary = byName(CreateGrantBody).principal_id?.summary ?? ""; - expect(summary).toBe("Principal id (`user_` or `team_`)."); + const summary = byName(CreateGrantBody).expires_at?.summary ?? ""; + expect(summary).toBe("Optional expiry."); expect(summary).not.toContain("\n"); }); @@ -67,9 +69,9 @@ describe("bodyFieldFlags", () => { string, { helpGroup?: string; description?: string; options?: string[]; multiple?: boolean } >; - expect(flags["principal-type"]?.helpGroup).toBe("REQUIRED FIELD"); - expect(flags["principal-type"]?.options).toEqual(["user", "team"]); - expect(flags["principal-type"]?.description).toMatch(/^\(required\) /); + expect(flags["relation"]?.helpGroup).toBe("REQUIRED FIELD"); + expect(flags["relation"]?.options).toEqual(["viewer", "editor", "admin"]); + expect(flags["relation"]?.description).toMatch(/^\(required\) /); expect(flags["expires-at"]?.helpGroup).toBe("OPTIONAL FIELD"); expect(flags["expires-at"]?.description).not.toMatch(/^\(required\)/); }); @@ -91,11 +93,10 @@ describe("bodyFromFlags", () => { it("collects scalar and enum flags under their wire names", () => { expect( bodyFromFlags(grantFields, { - "principal-type": "user", - "principal-id": "user_1", relation: "viewer", + "expires-at": "2030-01-01T00:00:00Z", }), - ).toEqual({ principal_type: "user", principal_id: "user_1", relation: "viewer" }); + ).toEqual({ relation: "viewer", expires_at: "2030-01-01T00:00:00Z" }); }); it("builds an object from repeated key=value entries", () => { @@ -193,9 +194,7 @@ describe("bodyFromFlags", () => { describe("fieldExample", () => { it("uses the required fields and their first allowed value", () => { - expect(fieldExample(describeBody(CreateGrantBody))).toBe( - "--principal-type user --principal-id --relation viewer", - ); + expect(fieldExample(describeBody(CreateGrantBody))).toBe("--relation viewer"); }); it("is absent when nothing is required", () => {