OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / auth / git-acl.js

151 lines · javascript ref:
/**
 * Git access control (issue #37, item 2) — pear-git-v3 specific.
 *
 * Kept separate from the shared auth pattern in check.js (which hypernote
 * / pear-git / hyper-social-web also use): serving git is unique to this
 * forge, so its ACL lives here.
 *
 * There is NO separate per-repo ACL store. Push rights reuse the SAME
 * IdentityRegistry user-perm model as check.js:requireWriter — a caller
 * resolves to a User and needs 'admin' or 'write'. Identity-linking means
 * any of a user's linked keys (HTTP session pubkey, signed-envelope root,
 * or swarm noise key) maps to the one user.
 *
 * Policy: only PUSH (git-receive-pack) is gated. Reads (git-upload-pack)
 * stay open — anonymous clone / fetch, CI, mirror replication, and public
 * browsing must keep working.
 */

import { resolveToken } from './access-token.js'
import { isIdentityRevoked } from './check.js'

/**
 * Whether a caller may run git-receive-pack (push).
 *
 *   - transport 'uds'  → trusted (local operator runs the daemon)
 *   - ownerMatched     → the daemon's own identity (intrinsic owner)
 *   - else resolve to an IdentityRegistry user and require admin|write.
 *
 * The caller is resolved by ONE of (the classic + p2p front doors, all
 * mapping to the same User — see spec/flows/classic-git-auth.md):
 *   - identityId/pubkey  (HTTP session or signed-envelope root)
 *   - token              (HTTPS PAT, `pgv3_…`, via resolveToken)
 *   - sshFingerprint     (SSH push, via the `sshkey:<fp>` reverse index)
 *   - noiseKey           (punch:// transport, via `noisekey:<hex>`)
 * All may be absent; absent + no match → denied (safe default).
 *
 * Token scope is an extra cap: a `read`-scoped token can NEVER push, even if
 * its user has write — the token bounds what that credential may do.
 *
 * @param {object} registry - IdentityRegistry (hyper-identity)
 * @param {object} caller
 * @param {string}  [caller.transport]      - 'uds' | 'http' | 'swarm' | 'ssh'
 * @param {boolean} [caller.ownerMatched]
 * @param {string}  [caller.identityId]     - hex pubkey / identity id
 * @param {string}  [caller.token]          - raw `pgv3_…` access token
 * @param {string}  [caller.sshFingerprint] - OpenSSH SHA256 fingerprint
 * @param {string}  [caller.noiseKey]       - hex swarm noise key
 * @returns {Promise<boolean>}
 */
export async function canPushGit (registry, caller = {}) {
  if (caller.transport === 'uds') return true
  if (caller.ownerMatched) return true
  if (!registry) return false

  // Access token: resolve + enforce the token's scope cap before perms.
  // A read-scoped token is denied push regardless of the user's perms.
  if (caller.token) {
    const resolved = await resolveToken(registry, caller.token)
    if (!resolved || resolved.scope !== 'write') return false
    return hasWrite(resolved.user)
  }

  const user = await resolveUser(registry, caller)
  return hasWrite(user)
}

function hasWrite (user) {
  const perms = user && Array.isArray(user.globalPermissions) ? user.globalPermissions : []
  return perms.includes('admin') || perms.includes('write')
}

/**
 * Resolve the caller's credential to an identityId, without judging it.
 * Each front door yields the SAME kind of value, so revocation can be applied
 * once, downstream — see resolveUser.
 */
async function resolveIdentityId (registry, caller) {
  if (caller.identityId) return caller.identityId

  if (caller.sshFingerprint && typeof registry.getIdentityBySSHKey === 'function') {
    const ident = await registry.getIdentityBySSHKey(caller.sshFingerprint).catch(() => null)
    if (ident && ident.identityId) return ident.identityId
  }

  if (caller.noiseKey && typeof registry.getIdentityByNoiseKey === 'function') {
    const ident = await registry.getIdentityByNoiseKey(caller.noiseKey).catch(() => null)
    if (ident && ident.identityId) return ident.identityId
  }

  return null
}

/**
 * Resolve a caller to a User, refusing revoked identities. (org #14)
 *
 * Revocation used to be checked in the sshFingerprint branch ONLY. The
 * identityId branch (HTTP sessions) and the noiseKey branch (punch://) both
 * called getUserByIdentity directly, which walks the user-identity link and
 * does NOT consult the revocation index — so a revoked identity kept pushing
 * over HTTP and punch:// for as long as its User still carried write/admin.
 *
 * Restructured so the three front doors only RESOLVE, and the revocation gate
 * sits on the single path they all funnel through. A revoked credential is a
 * definite denial, not a reason to fall through to the next branch: trying the
 * next door on a revoked one is how this class of bypass gets reintroduced.
 */
async function resolveUser (registry, caller) {
  if (typeof registry.getUserByIdentity !== 'function') return null

  const identityId = await resolveIdentityId(registry, caller)
  if (!identityId) return null

  if (await isIdentityRevoked(registry, identityId)) return null

  return registry.getUserByIdentity(identityId).catch(() => null)
}

/**
 * Resolve a caller to the User behind it, for READ authorization.
 *
 * Identity resolution ONLY — it does not decide access. Per-repo read policy
 * lives in hyper-orgs (`checkAccess`), reached via repo-access's isReader. This
 * answers "which user is asking?" so every read surface derives the caller the
 * same way instead of each inventing its own extraction.
 *
 * Shares resolveUser's funnel deliberately, so the revocation gate above
 * applies to reads too. Resolving identities separately on the read path would
 * reintroduce exactly the bypass documented on resolveUser — revocation checked
 * in one branch and missed in the others.
 *
 * Token scope differs from push: canPushGit requires scope === 'write', but a
 * READ-scoped token is a valid way to read. Both scopes resolve here; the scope
 * is REPORTED, not judged, so a caller needing the distinction can apply it.
 *
 * @param {object} registry - IdentityRegistry
 * @param {object} caller   - same shape as canPushGit's caller
 * @returns {Promise<{ user: object, scope: string|null } | null>}
 */
export async function resolveGitReader (registry, caller = {}) {
  if (!registry) return null

  if (caller.token) {
    const resolved = await resolveToken(registry, caller.token).catch(() => null)
    if (!resolved || !resolved.user) return null
    return { user: resolved.user, scope: resolved.scope || null }
  }

  const user = await resolveUser(registry, caller)
  return user ? { user, scope: null } : null
}