@nacre.work/core 0.5.7 → 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;
|