OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity commits / 897b792

auth: resource permissions resolve SPECIFIC before WILDCARD
formatUserPermissions picked a user's grant with a single resources.find(r => r.name === resourceName || r.name === '*') which returns the FIRST match. For a user holding [{'*': read}, {'secret-repo': admin}] a lookup of 'secret-repo' matched '*' and returned read-only — the named grant was silently shadowed, depending on ARRAY ORDER. That order is not hypothetical: autoRegister creates every user with resources: [{ name: '*', permissions: ['read'] }], so the wildcard is always first and any named grant appended later was unreachable. Per-resource permissions were therefore unreliable for any purpose, and both "private except for these people" and "public except narrower here" were inexpressible. Precedence is now explicit: an exact-name entry wins; '*' applies only when the resource has no entry of its own. This is what lets '*' mean "whatever is public" while a named grant still opens one private resource to one user — the model a read gate needs. Behaviour changes ONLY for a user holding both a wildcard and a specific entry for the same resource. Users with only [{'*': ...}] — i.e. every auto-registered user — are unaffected. Adds the first tests for this path (14 cases): exact-wins-over-wildcard in both array orders, narrower-named-grant, wildcard fallback, fallback to globalPermissions, the null-resourceName global check, admin implication, and the hasResourceAccess gates (unverified denied, admin short-circuit, action flag, resource scope, injected checkPermission). ⚠ Note for future test authors: constructing an AuthManager is NOT side- effect free. ChallengeManager and RateLimiter each start a setInterval that is never unref'd, so an undestroyed instance holds the event loop open and the runner hangs rather than exiting. The tests tear down via destroy(). That is likely why this path had no tests before. Verified: 9/9 hyper-identity test files pass. Consumer pear-git-v3: git-acl, p1-profile-links, tofu-no-remap, identity-record-replication, issue-index, profile-render, issues-mirror-writer, mirror-writer-enrollment all pass. cutover-prod-copy times out identically WITH THE CHANGE REVERTED — it does real swarm pulls against live peers, so it is environment-dependent and pre-existing, not a regression from this commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
897b792be0c986c420c8b7dc53b29db3c764f49f
by plankatron <fleeky@gmail.com> · 2026-07-31T14:47:38.000Z

Changed files (2) +179 -4

lib/auth.js +18 -4
……@@ -182,10 +182,24 @@ export class AuthManager {
182182 * @returns {Object} { read, write, admin, resources }
183183 */
184184 formatUserPermissions (user, resourceName) {
185 // Check resource-specific permissions
186 const resourcePerms = (user.resources || []).find(
187 r => r.name === resourceName || r.name === '*'
188 )
185 // Resource-specific permissions, SPECIFIC BEFORE WILDCARD.
186 //
187 // This used to be a single `.find(r => r.name === resourceName || r.name
188 // === '*')`, which returns the FIRST match — so for a user holding
189 // [{'*': read}, {'secret': admin}] a lookup of 'secret' matched '*' and
190 // returned read-only. Named grants were silently shadowed by the wildcard
191 // depending on array ORDER, which made per-resource permissions unreliable
192 // for any purpose, and made "readable by everyone except these named
193 // people" inexpressible.
194 //
195 // Precedence is now explicit: an exact-name entry wins; '*' applies only
196 // when the resource has no entry of its own. That is what lets '*' mean
197 // "whatever is public" while a named grant still opens one private
198 // resource to one user.
199 const resources = user.resources || []
200 const exact = resourceName ? resources.find(r => r.name === resourceName) : null
201 const wildcard = resources.find(r => r.name === '*')
202 const resourcePerms = exact || wildcard
189203
190204 if (resourcePerms) {
191205 return {
test/permissions.test.js +161 -0
……@@ -0,0 +1,161 @@
1// Permission evaluation: formatUserPermissions + hasResourceAccess.
2//
3// These are the first tests for this path. It carries the resource ACL that
4// decides read/write per named resource (repo, notebook, …) across every
5// consumer of this package, and it previously resolved a user's grants with a
6// single `.find()` that matched the wildcard or the exact name — whichever
7// came FIRST in the array. Named grants were therefore shadowed by '*'
8// depending on insertion order.
9
10import test from 'brittle'
11import { AuthManager } from '../lib/auth.js'
12
13// formatUserPermissions/hasResourceAccess are pure — no registry needed.
14//
15// But constructing an AuthManager is NOT side-effect free: ChallengeManager and
16// RateLimiter each start a setInterval that is never unref'd, so an undestroyed
17// instance holds the event loop open and the runner hangs instead of exiting.
18// Every instance must be torn down.
19function auth (t, opts = {}) {
20 const m = new AuthManager(opts)
21 t.teardown(() => m.destroy())
22 return m
23}
24
25const verified = (permissions) => ({ verified: true, permissions })
26
27test('exact-name grant WINS over a wildcard listed first', (t) => {
28 // The regression. Order here is the hostile one: '*' before the specific
29 // entry, which is also the order auto-registration produces (every user is
30 // created with [{'*': read}] and named grants get appended after).
31 const user = {
32 globalPermissions: ['read'],
33 resources: [
34 { name: '*', permissions: ['read'] },
35 { name: 'secret-repo', permissions: ['read', 'write', 'admin'] }
36 ]
37 }
38 const perms = auth(t).formatUserPermissions(user, 'secret-repo')
39 t.ok(perms.write, 'named write grant is not shadowed by the wildcard')
40 t.ok(perms.admin, 'named admin grant is not shadowed either')
41 t.alike(perms.resources, ['secret-repo'], 'reports the specific entry')
42})
43
44test('exact-name grant wins regardless of array order', (t) => {
45 const specificFirst = {
46 globalPermissions: ['read'],
47 resources: [
48 { name: 'secret-repo', permissions: ['read', 'write'] },
49 { name: '*', permissions: ['read'] }
50 ]
51 }
52 const wildcardFirst = {
53 globalPermissions: ['read'],
54 resources: [
55 { name: '*', permissions: ['read'] },
56 { name: 'secret-repo', permissions: ['read', 'write'] }
57 ]
58 }
59 const a = auth(t).formatUserPermissions(specificFirst, 'secret-repo')
60 const b = auth(t).formatUserPermissions(wildcardFirst, 'secret-repo')
61 t.alike(a, b, 'permission evaluation must not depend on array order')
62 t.ok(a.write)
63})
64
65test('an exact grant can be NARROWER than the wildcard', (t) => {
66 // The case that makes "private except for these people" expressible in
67 // reverse: broad default, restricted for one resource. Under the old
68 // first-match behaviour this was unrepresentable when '*' came first.
69 const user = {
70 globalPermissions: ['read', 'write'],
71 resources: [
72 { name: '*', permissions: ['read', 'write'] },
73 { name: 'locked', permissions: ['read'] }
74 ]
75 }
76 const perms = auth(t).formatUserPermissions(user, 'locked')
77 t.ok(perms.read)
78 t.absent(perms.write, 'the narrower named grant is honoured')
79})
80
81test('wildcard applies when the resource has no entry of its own', (t) => {
82 const user = {
83 globalPermissions: ['read'],
84 resources: [{ name: '*', permissions: ['read', 'write'] }]
85 }
86 const perms = auth(t).formatUserPermissions(user, 'anything-else')
87 t.ok(perms.write, 'falls back to the wildcard')
88 t.alike(perms.resources, ['*'])
89})
90
91test('no matching entry falls back to globalPermissions', (t) => {
92 const user = {
93 globalPermissions: ['read', 'admin'],
94 resources: [{ name: 'other-repo', permissions: ['read'] }]
95 }
96 const perms = auth(t).formatUserPermissions(user, 'unrelated')
97 t.ok(perms.admin, 'global admin applies when no resource entry matches')
98 t.alike(perms.resources, ['*'])
99})
100
101test('null resourceName resolves to the wildcard, not to a named entry', (t) => {
102 const user = {
103 globalPermissions: ['read'],
104 resources: [
105 { name: 'some-repo', permissions: ['admin'] },
106 { name: '*', permissions: ['read'] }
107 ]
108 }
109 const perms = auth(t).formatUserPermissions(user, null)
110 t.absent(perms.admin, 'a global check must not pick up a named grant')
111 t.alike(perms.resources, ['*'])
112})
113
114test('empty resources falls back to globalPermissions', (t) => {
115 const perms = auth(t).formatUserPermissions({ globalPermissions: ['read'], resources: [] }, 'x')
116 t.ok(perms.read)
117 t.absent(perms.write)
118})
119
120test('missing resources array does not throw', (t) => {
121 const perms = auth(t).formatUserPermissions({ globalPermissions: ['admin'] }, 'x')
122 t.ok(perms.admin)
123})
124
125test('admin implies read and write', (t) => {
126 const user = { globalPermissions: ['read'], resources: [{ name: 'r', permissions: ['admin'] }] }
127 const perms = auth(t).formatUserPermissions(user, 'r')
128 t.ok(perms.read)
129 t.ok(perms.write)
130 t.ok(perms.admin)
131})
132
133// ── hasResourceAccess ──────────────────────────────────────────
134
135test('hasResourceAccess: unverified auth is always denied', (t) => {
136 t.absent(auth(t).hasResourceAccess({ verified: false, permissions: { admin: true } }, 'r', 'read'))
137 t.absent(auth(t).hasResourceAccess(null, 'r', 'read'))
138})
139
140test('hasResourceAccess: admin short-circuits', (t) => {
141 t.ok(auth(t).hasResourceAccess(verified({ admin: true, resources: [] }), 'anything', 'write'))
142})
143
144test('hasResourceAccess: action flag must be present', (t) => {
145 const a = verified({ read: true, write: false, admin: false, resources: ['*'] })
146 t.ok(auth(t).hasResourceAccess(a, 'r', 'read'))
147 t.absent(auth(t).hasResourceAccess(a, 'r', 'write'))
148})
149
150test('hasResourceAccess: resource must be in scope', (t) => {
151 const a = verified({ read: true, admin: false, resources: ['repo-a'] })
152 t.ok(auth(t).hasResourceAccess(a, 'repo-a', 'read'))
153 t.absent(auth(t).hasResourceAccess(a, 'repo-b', 'read'), 'out-of-scope resource denied')
154})
155
156test('hasResourceAccess: an injected checkPermission overrides the default', (t) => {
157 const seen = []
158 const m = auth(t, { checkPermission: (a, action, resource) => { seen.push([action, resource]); return true } })
159 t.ok(m.hasResourceAccess(verified({ read: false, admin: false, resources: [] }), 'r', 'read'))
160 t.alike(seen, [['read', 'r']], 'called with (auth, action, resourceName)')
161})