Skip to content

Commit 35d287f

Browse files
adlerhurstcursoragentCopilot
authored
feat(database): Wave 1 authz MVP storage (#422) (#677)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Summary Wave 1 authz MVP storage on top of Wave 0 (#666): Goose migrations for the six tables, dual-write resource-scope identifiers + membership edges from existing project/team/user/membership statements, and `PersistCatalogVersion` for compiler `CatalogMutations` (#720). SQLite gets the same schema and dual-write path for local parity. Review / maintainability follow-ups: - Membership-edge deletes use one `DeleteAuthzMembershipEdges(filter)` API (kept `ForTeamDeactivate` for the subquery). - Shared `dialect/authz` holds multi-write user helpers, `MembershipEdgeSchema`, and catalog row builders (engines keep SQL). Removed the three identical dual-write adapter files and identity wrappers. - Typed authz discriminators (including DDL-aligned principal types), assignment scope constructors, and `AuthzMembershipEdgeKey`. - Spanner `activeUniqueKey` lives in Spanner storage (not domain). - Spanner membership-edge upsert uses a `created_at` no-op update (PK updates rejected). - Authz enum SQL parameters bind via `.String()` (matching `MembershipStatus`), including sibling ResourceKind/CatalogKind binds. - Spanner nullable authz params bind via `spannerNullString` / `spannerNullTime` (not Go pointers). - `CreateAuthzAssignment` mints empty IDs via `ensureManagedID` (`PrefixAuthzAssignment` / `asgn_`). - `GetAuthzCatalog` loads a persisted catalog + projected rows; deep Persist round-trip asserts live in `stmttest` (all dialects). Seeded `viewer→admin` closure depth is 2; Spanner relation-reference columns have DEFAULTs. - `authz_membership_edges` has Wave 0 set FK `(project_id, set_id) → teams` + MVP `CHECK (user∈team)` with `ON DELETE CASCADE` on **all** dialects (Spanner matched to Postgres/SQLite). `stmttest` `DeleteTeamCascadesEdges` asserts hard-deleting a team removes edges on every engine. ## Validation ```sh go generate ./internal/service/ go test ./internal/domain/ ./internal/service/ ./internal/storage/v2/dialect/authz/ ./internal/storage/v2/dialect/spanner/ -run ActiveUniqueKey go test -tags sqlite_integration ./internal/storage/v2/stmttest/ -run 'Authz|PersistCatalog|DualWrite' go test -tags postgres_integration ./internal/storage/v2/stmttest/ ./internal/storage/v2/dialect/postgres/ -run 'Authz|PersistCatalog|CatalogSeed|DualWrite' ``` Postgres + SQLite green (including `DeleteTeamCascadesEdges`). Spanner compile-checked + ActiveUniqueKey unit tests; full emulator coverage via CI `server:test-spanner`. ## Release notes / changeset Changeset already present: `.changeset/authz-mvp-storage.md` (`@zitadel/server` minor). Review/maintainability follow-ups are Go storage/API-shape refactors — no additional changeset. ## Notes - Stacked on Wave 0 (`cursor/permission-storage-wave0-6e44` / #666). - Re-synced with rewritten Wave 0 tip after main (`7f696931`): kept Wave 1 shipped schema; folded Wave 0 RSI MATCH SIMPLE + delete notes and `system-permission-catalog` see-also link. Skipped Wave 0 `grantor_type` null CHECK (not in Wave 1 migrations). - Out of scope: resolver (#423), Leopard, filling bundles, #420 auto-compile, Filter support for team-deactivate subqueries, splitting a second `dialect/authz*` package. <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-9a29c97f-a069-4f09-9bec-244a599e3354?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-9a29c97f-a069-4f09-9bec-244a599e3354&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Silvan <adlerhurst@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 8b1d5e3 commit 35d287f

60 files changed

Lines changed: 6507 additions & 488 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/authz-mvp-storage.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@zitadel/server": minor
3+
---
4+
5+
Add authorization MVP storage: resource_scope_index, system catalog seed, assignments, membership edges, and dual-write hooks.

docs/adrs/032-permission-catalogs.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -398,18 +398,19 @@ policies without reintroducing hard-coded levels and special cases.
398398
## Follow-ups
399399

400400
1. Design relational migrations for catalogs, permission/relation
401-
definitions, and assignments shared by both catalogs.
401+
definitions, expression edges / relation references, and assignments
402+
shared by both catalogs.
402403
Wave 0 DDL spike and locked decisions:
403404
[`docs/design/api/permission-storage.md`](../design/api/permission-storage.md)
404405
(implementation tracked by [issue #422](https://github.com/zitadel/nextgen/issues/422)).
405-
Wave 0 defers `authz_expression_edges` (D5); live catalog rows are
406-
`authz_relations` + `authz_relation_closure` until
407-
[issue #421](https://github.com/zitadel/nextgen/issues/421).
406+
Wave 1 (#422) ships `authz_expression_edges` + `authz_relation_references`
407+
as compiled #720 storage (superseding the Wave 0 D5/D14 “relations+closure
408+
only” deferral for MVP); bundle tables remain unfilled by the v1 mapper.
408409
Catalog-specific tables — `resource_scope_index` and app grants — are
409410
tracked in [ADR 033](033-internal-permission-management.md) and
410411
[ADR 034](034-external-permission-management.md); staff/support grant
411412
product is tracked by [issue #333](https://github.com/zitadel/nextgen/issues/333)
412-
(storage depiction in the Wave 0 doc).
413+
(storage depiction in the Wave 0 / Wave 1 doc).
413414
2. Add resolver conformance tests that compare single-resource checks and
414415
list predicates across PostgreSQL and Spanner.
415416
3. Validate the Leopard-style flattening approach for relation closure

docs/adrs/047-dialect-id-generation.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ the dialect generator is the only mint path, not that create always overwrites.
6464
| user TOTP row | `utotp` |
6565
| user recovery codes row | `urc` |
6666
| user passkey row | `upk` |
67+
| authz assignment | `asgn` |
6768

6869
Existing style mix (`brnd` / `flowdef` / `enc_key` / `upw`) stays until a
6970
dedicated rename PR. Do not add more without the selection rules below.

docs/design/api/permission-storage.md

Lines changed: 256 additions & 97 deletions
Large diffs are not rendered by default.

internal/authz/compiler/output.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package compiler
22

3-
import "github.com/zitadel/nextgen/internal/authz"
3+
import (
4+
"github.com/zitadel/nextgen/internal/authz"
5+
)
46

57
// Relation identifies one relation on one object type.
68
type Relation struct {

internal/domain/authz.go

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
package domain
2+
3+
import (
4+
"time"
5+
)
6+
7+
// System catalog identity (must match Goose seed).
8+
const (
9+
SystemCatalogID = "cat_sys_1"
10+
SystemCatalogOwnerID = "system"
11+
)
12+
13+
// PrefixAuthzAssignment is the dialect-minted ID prefix for authz_assignments (asgn_<opaque>).
14+
const PrefixAuthzAssignment ResourcePrefix = "asgn"
15+
16+
// ResourceKind is the resource_scope_index.resource_kind discriminator.
17+
type ResourceKind string
18+
19+
const (
20+
ResourceKindProject ResourceKind = "project"
21+
ResourceKindTeam ResourceKind = "team"
22+
ResourceKindUser ResourceKind = "user"
23+
)
24+
25+
func (k ResourceKind) String() string { return string(k) }
26+
27+
// AuthzMemberType is authz_membership_edges.member_type.
28+
type AuthzMemberType string
29+
30+
const (
31+
AuthzMemberTypeUser AuthzMemberType = "user"
32+
)
33+
34+
func (t AuthzMemberType) String() string { return string(t) }
35+
36+
// AuthzSetType is authz_membership_edges.set_type.
37+
type AuthzSetType string
38+
39+
const (
40+
AuthzSetTypeTeam AuthzSetType = "team"
41+
)
42+
43+
func (t AuthzSetType) String() string { return string(t) }
44+
45+
// AuthzCatalogKind is authz_catalogs.catalog_kind.
46+
type AuthzCatalogKind string
47+
48+
const (
49+
AuthzCatalogKindSystem AuthzCatalogKind = "system"
50+
AuthzCatalogKindAppGroup AuthzCatalogKind = "app_group"
51+
)
52+
53+
func (k AuthzCatalogKind) String() string { return string(k) }
54+
55+
// AuthzCatalogStatus is authz_catalogs.status.
56+
type AuthzCatalogStatus string
57+
58+
const (
59+
AuthzCatalogStatusDraft AuthzCatalogStatus = "draft"
60+
AuthzCatalogStatusActive AuthzCatalogStatus = "active"
61+
AuthzCatalogStatusRetired AuthzCatalogStatus = "retired"
62+
)
63+
64+
func (s AuthzCatalogStatus) String() string { return string(s) }
65+
66+
// AuthzRelationKind is authz_relations.kind.
67+
type AuthzRelationKind string
68+
69+
const (
70+
AuthzRelationKindRelation AuthzRelationKind = "relation"
71+
AuthzRelationKindPermission AuthzRelationKind = "permission"
72+
)
73+
74+
func (k AuthzRelationKind) String() string { return string(k) }
75+
76+
// AuthzPrincipalType is authz_assignments.principal_type (no project principals; see D13).
77+
type AuthzPrincipalType string
78+
79+
const (
80+
AuthzPrincipalTypeUser AuthzPrincipalType = "user"
81+
AuthzPrincipalTypeTeam AuthzPrincipalType = "team"
82+
AuthzPrincipalTypeAgent AuthzPrincipalType = "agent"
83+
AuthzPrincipalTypeSKProj AuthzPrincipalType = "sk_proj"
84+
AuthzPrincipalTypeSKTeam AuthzPrincipalType = "sk_team"
85+
)
86+
87+
func (t AuthzPrincipalType) String() string { return string(t) }
88+
89+
// AuthzScopeKind is authz_assignments.scope_kind.
90+
type AuthzScopeKind string
91+
92+
const (
93+
AuthzScopeKindProject AuthzScopeKind = "project"
94+
AuthzScopeKindTeam AuthzScopeKind = "team"
95+
AuthzScopeKindResource AuthzScopeKind = "resource"
96+
)
97+
98+
func (k AuthzScopeKind) String() string { return string(k) }
99+
100+
// AuthzCatalogVersion is the catalog row metadata written with a compiled
101+
// CatalogMutations payload (see PersistCatalogVersion).
102+
type AuthzCatalogVersion struct {
103+
ID string
104+
CatalogKind AuthzCatalogKind
105+
OwnerID string
106+
Version int
107+
SourceHash *string
108+
}
109+
110+
// AuthzCatalog is one persisted catalog version plus its projected relation
111+
// rows (authz_relations / references / expression_edges / relation_closure).
112+
type AuthzCatalog struct {
113+
ID string
114+
CatalogKind AuthzCatalogKind
115+
OwnerID string
116+
Version int
117+
Status AuthzCatalogStatus
118+
SourceHash *string
119+
Relations []AuthzRelation
120+
References []AuthzRelationReference
121+
Edges []AuthzExpressionEdge
122+
Closure []AuthzRelationClosure
123+
}
124+
125+
// AuthzRelation is one authz_relations row.
126+
type AuthzRelation struct {
127+
ObjectType string
128+
Relation string
129+
Kind AuthzRelationKind
130+
}
131+
132+
// AuthzRelationReference is one authz_relation_references row.
133+
type AuthzRelationReference struct {
134+
ObjectType string
135+
Relation string
136+
RefType string
137+
RefRelation string
138+
Wildcard bool
139+
Condition string
140+
Position int
141+
}
142+
143+
// AuthzExpressionEdge is one authz_expression_edges row.
144+
// Kind is the CHECK value: direct | computed_userset | tuple_to_userset.
145+
type AuthzExpressionEdge struct {
146+
ObjectType string
147+
Relation string
148+
Kind string
149+
SourceObjectType *string
150+
SourceRelation *string
151+
TuplesetObjectType *string
152+
TuplesetRelation *string
153+
Position int
154+
}
155+
156+
// AuthzRelationClosure is one authz_relation_closure row.
157+
type AuthzRelationClosure struct {
158+
FromObjectType string
159+
FromRelation string
160+
ToObjectType string
161+
ToRelation string
162+
Depth int
163+
}
164+
165+
// ResourceScope maps a globally addressable resource id to project/team scope
166+
// (resource_scope_index). Answers "where does this path.id live?" for middleware
167+
// and the future resolver — not a permission grant.
168+
type ResourceScope struct {
169+
ResourceID string
170+
ResourceKind ResourceKind
171+
ProjectID string
172+
TeamID *string
173+
CreatedAt time.Time
174+
UpdatedAt time.Time
175+
}
176+
177+
func NewProjectResourceScope(projectID string) *ResourceScope {
178+
return &ResourceScope{
179+
ResourceID: projectID,
180+
ResourceKind: ResourceKindProject,
181+
ProjectID: projectID,
182+
}
183+
}
184+
185+
func NewTeamResourceScope(projectID, teamID string) *ResourceScope {
186+
id := teamID
187+
return &ResourceScope{
188+
ResourceID: teamID,
189+
ResourceKind: ResourceKindTeam,
190+
ProjectID: projectID,
191+
TeamID: &id,
192+
}
193+
}
194+
195+
func NewUserResourceScope(projectID, userID string) *ResourceScope {
196+
return &ResourceScope{
197+
ResourceID: userID,
198+
ResourceKind: ResourceKindUser,
199+
ProjectID: projectID,
200+
}
201+
}
202+
203+
// AuthzAssignmentScope encodes the CHECK-constrained scope columns.
204+
type AuthzAssignmentScope struct {
205+
Kind AuthzScopeKind
206+
TeamID *string
207+
ResourceID *string
208+
}
209+
210+
func NewProjectAssignmentScope() AuthzAssignmentScope {
211+
return AuthzAssignmentScope{Kind: AuthzScopeKindProject}
212+
}
213+
214+
func NewTeamAssignmentScope(teamID string) AuthzAssignmentScope {
215+
id := teamID
216+
return AuthzAssignmentScope{Kind: AuthzScopeKindTeam, TeamID: &id}
217+
}
218+
219+
func NewResourceAssignmentScope(resourceID string) AuthzAssignmentScope {
220+
id := resourceID
221+
return AuthzAssignmentScope{Kind: AuthzScopeKindResource, ResourceID: &id}
222+
}
223+
224+
// AuthzAssignment binds a principal to a catalog relation at an explicit scope.
225+
// This is a grant row for the grants API / resolver — not dual-written from
226+
// ordinary CreateUser/CreateTeam.
227+
//
228+
// Relation identity is (ObjectType, Relation), matching authz_relations.
229+
type AuthzAssignment struct {
230+
ID string
231+
ProjectID string
232+
CatalogID string
233+
PrincipalType AuthzPrincipalType
234+
PrincipalID string
235+
ObjectType string
236+
Relation string
237+
ScopeKind AuthzScopeKind
238+
ScopeTeamID *string
239+
ScopeResourceID *string
240+
GrantorType *string
241+
GrantorID *string
242+
DelegationID *string
243+
ExpiresAt *time.Time
244+
RevokedAt *time.Time
245+
CreatedAt time.Time
246+
UpdatedAt time.Time
247+
}
248+
249+
// ApplyScope sets the scope columns from a constructor-built scope value.
250+
func (a *AuthzAssignment) ApplyScope(scope AuthzAssignmentScope) {
251+
a.ScopeKind = scope.Kind
252+
a.ScopeTeamID = scope.TeamID
253+
a.ScopeResourceID = scope.ResourceID
254+
}
255+
256+
// AuthzMembershipEdge is the authz projection of set membership (not lifecycle).
257+
// The resolver expands team grants through these edges; team_memberships remains
258+
// the roster table and is not read at check time.
259+
type AuthzMembershipEdge struct {
260+
ProjectID string
261+
MemberType AuthzMemberType
262+
MemberID string
263+
SetType AuthzSetType
264+
SetID string
265+
CreatedAt time.Time
266+
}
267+
268+
// AuthzMembershipEdgeKey identifies one authz_membership_edges row.
269+
type AuthzMembershipEdgeKey struct {
270+
ProjectID string
271+
SetType AuthzSetType
272+
SetID string
273+
MemberType AuthzMemberType
274+
MemberID string
275+
}
276+
277+
func NewUserTeamMembershipEdge(projectID, teamID, userID string) *AuthzMembershipEdge {
278+
return &AuthzMembershipEdge{
279+
ProjectID: projectID,
280+
MemberType: AuthzMemberTypeUser,
281+
MemberID: userID,
282+
SetType: AuthzSetTypeTeam,
283+
SetID: teamID,
284+
}
285+
}
286+
287+
func NewUserTeamMembershipEdgeKey(projectID, teamID, userID string) AuthzMembershipEdgeKey {
288+
return AuthzMembershipEdgeKey{
289+
ProjectID: projectID,
290+
SetType: AuthzSetTypeTeam,
291+
SetID: teamID,
292+
MemberType: AuthzMemberTypeUser,
293+
MemberID: userID,
294+
}
295+
}
296+
297+
// AuthzMembershipEdgeField enumerates AuthzMembershipEdge columns for filter deletes.
298+
type AuthzMembershipEdgeField uint8
299+
300+
const (
301+
AuthzMembershipEdgeFieldUnspecified AuthzMembershipEdgeField = iota
302+
AuthzMembershipEdgeFieldProjectID
303+
AuthzMembershipEdgeFieldMemberType
304+
AuthzMembershipEdgeFieldMemberID
305+
AuthzMembershipEdgeFieldSetType
306+
AuthzMembershipEdgeFieldSetID
307+
AuthzMembershipEdgeFieldCreatedAt
308+
)

internal/domain/team_membership.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ const (
1414

1515
func (s MembershipStatus) String() string { return string(s) }
1616

17+
// IsAuthzActive reports whether the roster status should project an authz membership edge.
18+
func (s MembershipStatus) IsAuthzActive() bool {
19+
return s == MembershipStatusActive
20+
}
21+
1722
// RosterMembershipStatuses are the participation states that keep a user on a
1823
// team's roster. [MembershipStatusRemoved] is deliberately absent: a removed
1924
// membership is history, not roster, and roster reads must not serve it.

0 commit comments

Comments
 (0)