OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / repo-access.js

211 lines · javascript ref:
/**
 * repo-access — the single authority for "may this caller read/write this repo?"
 *
 * WHY THIS EXISTS
 *
 * pear-git-v3 has a write-authorization model and effectively no read model.
 * Every transport gates `git-receive-pack` and lets `git-upload-pack` through:
 *
 *   services/sync.js:65        "git-upload-pack (read) stays open"
 *   handlers/repos.js:709-710  "reads (git-upload-pack) stay open"
 *   handlers/ssh-keys.js:98    op !== 'receive-pack' -> { allowed: true }
 *
 * and there are ~12 independent read surfaces (P2P sync topic, tracker room,
 * overlay-by-hash, HTTP smart-git, SSH, web browser, search, /files/raw,
 * releases, docs, get-repo RPC). None of them consults any visibility state,
 * and there is no shared repo-resolution helper to hang a check on —
 * `repo-name.js isValidRepoName()` is a validator, not a gate.
 *
 * So every surface defaults to open, and every FUTURE surface will too. This
 * module is the choke point that fixes the default. Surfaces call it; a
 * surface that forgets is a test failure, not a silent leak.
 *
 * RELATIONSHIP TO #138 (export-ok)
 *
 * Complementary, not alternatives:
 *   - #138 keeps a repo OUT of the P2P layer entirely (never onboarded: no
 *     tracker, no room, no bundles). Prevention, no per-request cost.
 *   - this module governs the request-shaped surfaces (HTTP/SSH/web/RPC),
 *     where there is a caller identity to judge.
 * A bare repo on disk is servable over smart-HTTP whether or not it was ever
 * onboarded, so #138 alone does not make a repo private.
 *
 * DEFAULT
 *
 * Read access DENIES unless the repo is marked public. The current default —
 * open — is the bug. But flipping it live would un-serve every existing repo,
 * so `enforce` starts false: the predicate computes and reports the decision
 * without acting on it, letting a deployment observe what WOULD break before
 * anything does. See the migration sequence on #138.
 *
 * NOT REPLICATED
 *
 * The marker is local on-disk state, deliberately. "Do I serve this?" is a
 * per-peer property — consistent with #135, where byte removal is already a
 * per-peer operator step. If it replicated, marking a repo private on one
 * backend would un-serve it on all of them, which is wrong for a repo
 * deliberately served by exactly one machine.
 */

import fs from '#node:fs'
import { join } from '#node:path'

/** Presence of this file in `<name>.git/` marks a repo as publicly readable. */
export const PUBLIC_MARKER = 'pear-git-export-ok'

/**
 * Mark a repo public, idempotently. Called wherever a bare repo comes into
 * existence — repo creation, a peer materializing a repo it joined, and the
 * cross-peer pull path.
 *
 * WHY AUTO-MARK RATHER THAN DEFAULT-PRIVATE: without this, hosting a repo
 * silently becomes a two-step people forget, and every mirror-materialized
 * repo would go unserved the moment enforcement turns on — the fleet would
 * degrade quietly instead of failing loudly.
 *
 * WHY THAT IS SAFE: it cannot leak a private repo, by construction. An
 * unexported repo is never onboarded into the P2P layer (no tracker, no room
 * joined, no bundles published — forge #138), so it never replicates, so a
 * peer can only ever materialize a repo that was ALREADY public at the source.
 * Marking what you just pulled or created matches what it already was.
 *
 * New repos are public by default, preserving today's behaviour — every repo
 * is currently readable. `pgh repo visibility <name> private` opts out.
 *
 * @returns {boolean} true if a marker was written, false if already present or
 *   the write failed (never throws — a marker is not worth failing a clone).
 */
export function ensureExportMarker (reposDir, name, _fs = fs) {
  if (!reposDir || !name) return false
  try {
    const p = join(reposDir, name + '.git', PUBLIC_MARKER)
    if (_fs.existsSync(p)) return false
    _fs.writeFileSync(p, '')
    return true
  } catch {
    return false
  }
}

export const DENY_RETIRED = 'retired'
export const DENY_UNMARKED = 'not-marked-public'
export const DENY_BAD_NAME = 'invalid-repo-name'
export const ALLOW_PUBLIC = 'public'
export const ALLOW_LOCAL = 'local-transport'
export const ALLOW_READER = 'authorized-reader'

// Transports that are already the operator's own machine. UDS is the daemon's
// local socket; treating it as readable keeps `pgh` working on the host for
// repos the operator deliberately keeps unpublished — the "host it privately"
// case. HTTP and swarm are never local.
const LOCAL_TRANSPORTS = new Set(['uds', 'local'])

/**
 * @param {object} o
 *   o.reposDir    dir containing `<name>.git` bares
 *   o.isRetired   (name) => boolean  — RetiredRepos.isRetired, same shape
 *   o.enforce     boolean — false (default) computes but does not deny
 *   o.fs          injectable for tests
 *   o.isValidName (name) => boolean — repo-name.js isValidRepoName
 *   o.isReader    async (name, ctx) => boolean — identity-based read grant,
 *                 consulted ONLY by the `.async` variant and ONLY for a repo
 *                 that is merely unmarked. Injected rather than imported so
 *                 this module stays dependency-free and unit-testable.
 * @returns {(name: string, ctx?: object) => { read, write, reason, wouldDeny, enforced }}
 *          with an `.async(name, ctx) => Promise<same>` identity-aware variant.
 */
export function createRepoAccess (o = {}) {
  const reposDir = o.reposDir
  const _fs = o.fs || fs
  const isRetired = typeof o.isRetired === 'function' ? o.isRetired : () => false
  const isValidName = typeof o.isValidName === 'function' ? o.isValidName : () => true
  const enforce = o.enforce === true

  if (!reposDir) throw new Error('createRepoAccess requires { reposDir }')

  function isMarkedPublic (name) {
    try {
      return _fs.existsSync(join(reposDir, name + '.git', PUBLIC_MARKER))
    } catch {
      return false
    }
  }

  /**
   * Decide access. Never throws — a surface must be able to call this on any
   * input, including hostile input, and get a decision.
   */
  function repoAccess (name, ctx = {}) {
    const decide = (allowRead, reason) => {
      // When not enforcing, report the decision but do not act on it. This is
      // what makes the rollout observable: callers log `wouldDeny` and the
      // fleet keeps serving until the operator flips `enforce`.
      const read = enforce ? allowRead : true
      return {
        read,
        write: false, // write authz is unchanged and still owned by the existing push gates
        reason,
        wouldDeny: !allowRead,
        enforced: enforce
      }
    }

    if (!name || !isValidName(name)) return decide(false, DENY_BAD_NAME)

    // Retirement outranks everything — a tombstoned repo is never readable,
    // regardless of markers. Predicates compose; retired ∧ public => denied.
    if (isRetired(name)) return decide(false, DENY_RETIRED)

    // The operator's own machine. Deliberately NOT identity-based: identity
    // checks are async (tracker writer-set lookups) and every call site here
    // is synchronous. Per-identity read grants are the next increment.
    if (LOCAL_TRANSPORTS.has(ctx.transport)) return decide(true, ALLOW_LOCAL)

    if (isMarkedPublic(name)) return decide(true, ALLOW_PUBLIC)

    return decide(false, DENY_UNMARKED)
  }

  /**
   * Identity-aware variant. Same decision, plus one extra chance: a repo that
   * is merely UNMARKED can still be read by an authorized reader (repo writer
   * set / org membership). That lookup is async — it touches the tracker — so
   * it cannot live in the sync path, which is why both exist.
   *
   * Only DENY_UNMARKED is rescuable. Retirement and an invalid name are final:
   * no identity should resurrect a tombstoned repo, and no identity makes a
   * traversal string into a repo name.
   *
   * `isReader` failing is a DENY, not an allow — a tracker that is unreachable
   * or mid-migration must not open a private repo. Fail closed, always.
   *
   * Use this wherever the caller has an identity (web, RPC). The sync form
   * remains correct for transports that have none (anonymous HTTP, SSH is
   * keyed but resolved elsewhere).
   */
  repoAccess.async = async function repoAccessAsync (name, ctx = {}) {
    const base = repoAccess(name, ctx)
    if (!base.wouldDeny) return base
    if (base.reason !== DENY_UNMARKED) return base
    if (typeof o.isReader !== 'function') return base

    let ok = false
    try {
      ok = await o.isReader(name, ctx) === true
    } catch {
      ok = false // fail closed
    }
    if (!ok) return base

    return {
      read: true,
      write: false,
      reason: ALLOW_READER,
      wouldDeny: false,
      enforced: base.enforced
    }
  }

  return repoAccess
}