| … | … | @@ -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 | |
| 10 | import test from 'brittle' |
| 11 | import { 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. |
| 19 | function auth (t, opts = {}) { |
| 20 | const m = new AuthManager(opts) |
| 21 | t.teardown(() => m.destroy()) |
| 22 | return m |
| 23 | } |
| 24 | |
| 25 | const verified = (permissions) => ({ verified: true, permissions }) |
| 26 | |
| 27 | test('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 | |
| 44 | test('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 | |
| 65 | test('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 | |
| 81 | test('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 | |
| 91 | test('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 | |
| 101 | test('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 | |
| 114 | test('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 | |
| 120 | test('missing resources array does not throw', (t) => { |
| 121 | const perms = auth(t).formatUserPermissions({ globalPermissions: ['admin'] }, 'x') |
| 122 | t.ok(perms.admin) |
| 123 | }) |
| 124 | |
| 125 | test('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 | |
| 135 | test('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 | |
| 140 | test('hasResourceAccess: admin short-circuits', (t) => { |
| 141 | t.ok(auth(t).hasResourceAccess(verified({ admin: true, resources: [] }), 'anything', 'write')) |
| 142 | }) |
| 143 | |
| 144 | test('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 | |
| 150 | test('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 | |
| 156 | test('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 | }) |