OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / test / permissions.test.js

162 lines · javascript ref:
// Permission evaluation: formatUserPermissions + hasResourceAccess.
//
// These are the first tests for this path. It carries the resource ACL that
// decides read/write per named resource (repo, notebook, …) across every
// consumer of this package, and it previously resolved a user's grants with a
// single `.find()` that matched the wildcard or the exact name — whichever
// came FIRST in the array. Named grants were therefore shadowed by '*'
// depending on insertion order.

import test from 'brittle'
import { AuthManager } from '../lib/auth.js'

// formatUserPermissions/hasResourceAccess are pure — no registry needed.
//
// But 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 instead of exiting.
// Every instance must be torn down.
function auth (t, opts = {}) {
  const m = new AuthManager(opts)
  t.teardown(() => m.destroy())
  return m
}

const verified = (permissions) => ({ verified: true, permissions })

test('exact-name grant WINS over a wildcard listed first', (t) => {
  // The regression. Order here is the hostile one: '*' before the specific
  // entry, which is also the order auto-registration produces (every user is
  // created with [{'*': read}] and named grants get appended after).
  const user = {
    globalPermissions: ['read'],
    resources: [
      { name: '*', permissions: ['read'] },
      { name: 'secret-repo', permissions: ['read', 'write', 'admin'] }
    ]
  }
  const perms = auth(t).formatUserPermissions(user, 'secret-repo')
  t.ok(perms.write, 'named write grant is not shadowed by the wildcard')
  t.ok(perms.admin, 'named admin grant is not shadowed either')
  t.alike(perms.resources, ['secret-repo'], 'reports the specific entry')
})

test('exact-name grant wins regardless of array order', (t) => {
  const specificFirst = {
    globalPermissions: ['read'],
    resources: [
      { name: 'secret-repo', permissions: ['read', 'write'] },
      { name: '*', permissions: ['read'] }
    ]
  }
  const wildcardFirst = {
    globalPermissions: ['read'],
    resources: [
      { name: '*', permissions: ['read'] },
      { name: 'secret-repo', permissions: ['read', 'write'] }
    ]
  }
  const a = auth(t).formatUserPermissions(specificFirst, 'secret-repo')
  const b = auth(t).formatUserPermissions(wildcardFirst, 'secret-repo')
  t.alike(a, b, 'permission evaluation must not depend on array order')
  t.ok(a.write)
})

test('an exact grant can be NARROWER than the wildcard', (t) => {
  // The case that makes "private except for these people" expressible in
  // reverse: broad default, restricted for one resource. Under the old
  // first-match behaviour this was unrepresentable when '*' came first.
  const user = {
    globalPermissions: ['read', 'write'],
    resources: [
      { name: '*', permissions: ['read', 'write'] },
      { name: 'locked', permissions: ['read'] }
    ]
  }
  const perms = auth(t).formatUserPermissions(user, 'locked')
  t.ok(perms.read)
  t.absent(perms.write, 'the narrower named grant is honoured')
})

test('wildcard applies when the resource has no entry of its own', (t) => {
  const user = {
    globalPermissions: ['read'],
    resources: [{ name: '*', permissions: ['read', 'write'] }]
  }
  const perms = auth(t).formatUserPermissions(user, 'anything-else')
  t.ok(perms.write, 'falls back to the wildcard')
  t.alike(perms.resources, ['*'])
})

test('no matching entry falls back to globalPermissions', (t) => {
  const user = {
    globalPermissions: ['read', 'admin'],
    resources: [{ name: 'other-repo', permissions: ['read'] }]
  }
  const perms = auth(t).formatUserPermissions(user, 'unrelated')
  t.ok(perms.admin, 'global admin applies when no resource entry matches')
  t.alike(perms.resources, ['*'])
})

test('null resourceName resolves to the wildcard, not to a named entry', (t) => {
  const user = {
    globalPermissions: ['read'],
    resources: [
      { name: 'some-repo', permissions: ['admin'] },
      { name: '*', permissions: ['read'] }
    ]
  }
  const perms = auth(t).formatUserPermissions(user, null)
  t.absent(perms.admin, 'a global check must not pick up a named grant')
  t.alike(perms.resources, ['*'])
})

test('empty resources falls back to globalPermissions', (t) => {
  const perms = auth(t).formatUserPermissions({ globalPermissions: ['read'], resources: [] }, 'x')
  t.ok(perms.read)
  t.absent(perms.write)
})

test('missing resources array does not throw', (t) => {
  const perms = auth(t).formatUserPermissions({ globalPermissions: ['admin'] }, 'x')
  t.ok(perms.admin)
})

test('admin implies read and write', (t) => {
  const user = { globalPermissions: ['read'], resources: [{ name: 'r', permissions: ['admin'] }] }
  const perms = auth(t).formatUserPermissions(user, 'r')
  t.ok(perms.read)
  t.ok(perms.write)
  t.ok(perms.admin)
})

// ── hasResourceAccess ──────────────────────────────────────────

test('hasResourceAccess: unverified auth is always denied', (t) => {
  t.absent(auth(t).hasResourceAccess({ verified: false, permissions: { admin: true } }, 'r', 'read'))
  t.absent(auth(t).hasResourceAccess(null, 'r', 'read'))
})

test('hasResourceAccess: admin short-circuits', (t) => {
  t.ok(auth(t).hasResourceAccess(verified({ admin: true, resources: [] }), 'anything', 'write'))
})

test('hasResourceAccess: action flag must be present', (t) => {
  const a = verified({ read: true, write: false, admin: false, resources: ['*'] })
  t.ok(auth(t).hasResourceAccess(a, 'r', 'read'))
  t.absent(auth(t).hasResourceAccess(a, 'r', 'write'))
})

test('hasResourceAccess: resource must be in scope', (t) => {
  const a = verified({ read: true, admin: false, resources: ['repo-a'] })
  t.ok(auth(t).hasResourceAccess(a, 'repo-a', 'read'))
  t.absent(auth(t).hasResourceAccess(a, 'repo-b', 'read'), 'out-of-scope resource denied')
})

test('hasResourceAccess: an injected checkPermission overrides the default', (t) => {
  const seen = []
  const m = auth(t, { checkPermission: (a, action, resource) => { seen.push([action, resource]); return true } })
  t.ok(m.hasResourceAccess(verified({ read: false, admin: false, resources: [] }), 'r', 'read'))
  t.alike(seen, [['read', 'r']], 'called with (auth, action, resourceName)')
})