@nacre.work/core 0.5.8 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,130 @@
1
+ -- 0025 — an application may act as the person who approved it.
2
+ --
3
+ -- OAuth exists so a *person* can let an application act for them, and until now
4
+ -- the consent flow could only be completed by an `org_admin`: the only thing it
5
+ -- could offer was a service account, and both listing and minting one are
6
+ -- `org_admin`. A member who followed an MCP client's link reached a screen they
7
+ -- could not use — an empty picker, then a 404 after pressing Approve.
8
+ --
9
+ -- The specification is docs/authz.md, section "Delegated authority". The part
10
+ -- this file is responsible for:
11
+ --
12
+ -- * A delegation is **not** a principal. Nothing is granted to one, so
13
+ -- `grants.principal_type` is untouched and still admits exactly `user`,
14
+ -- `group` and `service_account`. With no second grant set there is no
15
+ -- intersection to compute, and therefore no new way for the resolver to be
16
+ -- wrong.
17
+ --
18
+ -- * A delegation is a *variant of a connection*, not a second mechanism.
19
+ -- 0024 already built the standing connection, its rotation and its
20
+ -- revocation; all this adds is what the connection acts as.
21
+
22
+ -- ───────────────────────── what a connection acts as ─────────────────────────
23
+
24
+ -- A discriminator rather than "service_account_id IS NULL means delegation".
25
+ -- Inferring a mode from a null is how a later `LEFT JOIN` silently produces a
26
+ -- third state nobody designed, and this column is read on the authentication
27
+ -- path where a surprise is a security question.
28
+ ALTER TABLE oauth_consents
29
+ ADD COLUMN acts_as text NOT NULL DEFAULT 'service_account'
30
+ CHECK (acts_as IN ('service_account', 'user'));
31
+
32
+ -- Null for a delegation: it acts as `approved_by`, which is already here and
33
+ -- already references `users(id)`.
34
+ ALTER TABLE oauth_consents ALTER COLUMN service_account_id DROP NOT NULL;
35
+
36
+ -- The two modes, stated so the database refuses a row that is half of each.
37
+ -- Without this, dropping NOT NULL above would admit a service-account
38
+ -- connection that names no service account.
39
+ ALTER TABLE oauth_consents
40
+ ADD CONSTRAINT oauth_consents_acts_as_shape CHECK (
41
+ (acts_as = 'service_account' AND service_account_id IS NOT NULL)
42
+ OR (acts_as = 'user' AND service_account_id IS NULL)
43
+ );
44
+
45
+ -- 0024's `UNIQUE (org_id, client_id, service_account_id)` stops being enough:
46
+ -- NULLs are distinct in a unique index, so approving the same application twice
47
+ -- as yourself would make a second connection, and ending one would leave the
48
+ -- other working. One connection per application per person, same rule as the
49
+ -- one 0024 states for agents.
50
+ CREATE UNIQUE INDEX oauth_consents_delegation_key
51
+ ON oauth_consents (org_id, client_id, approved_by)
52
+ WHERE acts_as = 'user';
53
+
54
+ -- The code that becomes that connection's first token carries the same
55
+ -- discriminator, for the same reason: `redeem` returns what the token endpoint
56
+ -- mints from, and inferring the mode from a null there would put the guess on
57
+ -- the issuance path instead of the authentication one.
58
+ --
59
+ -- `consent_id IS NOT NULL` is part of the delegated shape rather than a
60
+ -- convention. The token's `del` claim *is* the connection's id, so a delegated
61
+ -- authorization with nothing to point at would be a code that can only mint a
62
+ -- token no request can check.
63
+ ALTER TABLE oauth_authorizations
64
+ ADD COLUMN acts_as text NOT NULL DEFAULT 'service_account'
65
+ CHECK (acts_as IN ('service_account', 'user'));
66
+
67
+ ALTER TABLE oauth_authorizations ALTER COLUMN service_account_id DROP NOT NULL;
68
+
69
+ ALTER TABLE oauth_authorizations
70
+ ADD CONSTRAINT oauth_authorizations_acts_as_shape CHECK (
71
+ (acts_as = 'service_account' AND service_account_id IS NOT NULL)
72
+ OR (acts_as = 'user' AND service_account_id IS NULL AND consent_id IS NOT NULL)
73
+ );
74
+
75
+ -- ─────────────────────────────── the narrowing ───────────────────────────────
76
+
77
+ -- Chosen layers, and a row per layer rather than a `uuid[]` column.
78
+ --
79
+ -- An array cannot carry a foreign key, so a deleted layer would leave an id in
80
+ -- it forever — and `DELETE /v1/layers/{id}` exists and already takes the grants
81
+ -- naming the layer with it. A join table gets that cascade from the database
82
+ -- instead of from whoever remembers to write it.
83
+ --
84
+ -- No rows for a connection means **no narrowing**, not "narrowed to nothing".
85
+ -- The two are opposite and the code must not conflate them: a delegation
86
+ -- narrowed to zero layers is one that can reach nothing, and it is not a state
87
+ -- this table can express, deliberately — a caller with an empty narrowing gets
88
+ -- an empty result without a query, which is what `buildFilter` already refuses
89
+ -- to express as a filter.
90
+ CREATE TABLE oauth_consent_layers (
91
+ org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
92
+ consent_id uuid NOT NULL REFERENCES oauth_consents(id) ON DELETE CASCADE,
93
+ layer_id uuid NOT NULL,
94
+
95
+ PRIMARY KEY (consent_id, layer_id),
96
+ -- Composite, so a narrowing cannot name a layer in another organization.
97
+ -- The database refuses it rather than the code that writes the insert —
98
+ -- the rule db-migration states for every foreign key that crosses tenants.
99
+ FOREIGN KEY (layer_id, org_id) REFERENCES layers (id, org_id) ON DELETE CASCADE
100
+ );
101
+
102
+ CREATE INDEX ON oauth_consent_layers (org_id, consent_id);
103
+
104
+ ALTER TABLE oauth_consent_layers ENABLE ROW LEVEL SECURITY;
105
+ ALTER TABLE oauth_consent_layers FORCE ROW LEVEL SECURITY;
106
+ CREATE POLICY org_isolation ON oauth_consent_layers
107
+ USING (org_id = current_setting('app.current_org')::uuid);
108
+
109
+ -- ────────────────────── what the authentication path reads ──────────────────────
110
+
111
+ -- The check docs/authz.md specifies, as an index rather than as a hope: every
112
+ -- request carrying a delegated token joins `oauth_consents` to `users` by
113
+ -- primary key, and refuses on a missing row, on `revoked_at`, on
114
+ -- `users.disabled`, or on `users.role = 'platform_admin'`.
115
+ --
116
+ -- It is deliberately **not** cached. The effective-principals cache keys on
117
+ -- `organizations.groups_version`, which triggers bump on `groups`,
118
+ -- `group_members` and `grants` — and not on `users`. Putting this behind that
119
+ -- cache would let a disabled user's delegations keep working for the TTL, which
120
+ -- is the lag the ACL tag cache was removed for. One indexed read, on a
121
+ -- connection the request already holds.
122
+ --
123
+ -- `oauth_consents` is already RLS'd to the organization and the token names the
124
+ -- organization, so this reads inside `withOrg` like everything else. There is no
125
+ -- cross-tenant lookup here and therefore no `app.authenticating` policy: unlike
126
+ -- a refresh token, a delegated *access* token says which organization it belongs
127
+ -- to before anything is read.
128
+ CREATE INDEX ON oauth_consents (org_id, id) WHERE revoked_at IS NULL;
129
+
130
+ GRANT SELECT, INSERT, DELETE ON oauth_consent_layers TO nacre_app;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nacre.work/core",
3
- "version": "0.5.8",
3
+ "version": "0.6.0",
4
4
  "description": "Nacre core: data model, permission resolver, and shared types",
5
5
  "repository": {
6
6
  "type": "git",