Files
pig/apps/api/src/lib/auth.ts
T
karti 13dec6b4b8
CI / verify (push) Successful in 3m45s
CI / publish (push) Has been skipped
Rebuild the shell, add Calendar and Learn, and govern reads
Seven parallel agents and an adversarial verification pass. The three things
worth knowing before reading the diff:

RBAC WAS ALREADY BUILT. docs/build-plan.md marks F2 and F3 outstanding and is
stale — packages/core/src/permissions.ts and lib/mutation.ts shipped long ago.
So this does not rebuild them; it closes the gaps an audit found. The big one
is that reads were entirely ungoverned: every GET was "any authenticated
member", so a junior demand rep and a research contractor could both pull
per-block supplier cost and break-even prices from /api/capacity/margin, and
every contract's negotiated terms. For a company whose margin is the business,
that was the hole that mattered. Adds book:read / economics:read / team:read,
a readGuard middleware, and a `viewer` role below member.

THE BUTTON AND THE 403 DISAGREED — the exact thing F3 said must never happen.
Contracts.tsx never called can() at all, so its save button was always enabled
against a server requiring contract:sign; Capacity.tsx gated commitment
creation on deal:write/demand while the server wanted commitment:write/supply.

POST /api/activities was the one write bypassing executeMutation: no capability
check, and any member could mutate accounts.lastActivityAt as a side effect.
It is now a proper mutation() behind activity:write.

The shell becomes three panes — a collapsible shadcn sidebar with an account
switcher on the Piggy accent, a header with real search, and Piggy docked to
the right, page-aware and persistent across navigation. The phone keeps its
bottom tab bar, which is the thing this product already beat trycompai/crm on,
and gains the sidebar as a sheet.

Calendar is a projection over thirteen dated sources rather than a new table,
because a table would duplicate dates that already live on contracts, deals and
commitments and would drift — and one ledger answering the question is the
whole argument. It surfaces export_authorizations and compliance_artifacts,
which had indexed expires_at columns, schema comments saying they must be
alerted on, and no read endpoint or UI anywhere.

Learn carries two tracks. Concepts are members-only; the platform track can be
opened with a share code by someone with no account. The code mints a scoped
learn-only token and never a Principal — every route here resolves a principal
and then checks capabilities, so a principal-minting code would be one missing
check away from leaking the book. "Only platform-track rows may be code-visible"
is a database CHECK constraint as well as a write-path rule, and a test asserts
a valid learn token still gets 401 on /api/dashboard, /api/accounts and
/api/contracts — the same invariant scripts/deploy.sh refuses to ship without.

CD becomes tag-to-ship. CI publishes an image to the Gitea registry on a
release-* tag and cloud-2 pulls it, so no credential on the shared runner can
execute anything on production — by construction rather than by policy. Both
halves of deploy.sh's original rule survive: nothing on the runner reaches the
host, and a human still decides when it ships. deploy.sh gains a rollback and a
public-origin check, and PIG_IMAGE now reaches compose through `sudo env`,
without which sudo's env_reset silently resolved every release to pig:local.

Tests 141 -> 261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:02:48 -07:00

325 lines
11 KiB
TypeScript

/**
* Authentication and authorization.
*
* The distinction is the whole point of this file, and it is the thing most
* likely to be got wrong by someone extending PIG later:
*
* **Authentication** answers "who is this?" and is delegated to an identity
* provider. PIG stores no passwords and issues no sessions of its own.
*
* **Authorization** answers "may they use PIG?" and is answered ONLY by a row
* in PIG's `users` table.
*
* These must stay separate because the Supabase project may be shared with
* other applications. A valid token proves someone has an account *somewhere in
* that project* — not that they belong here. Treating a verified token as
* sufficient would silently grant every user of every sibling application full
* access to the CRM.
*
* A token with no matching PIG user gets 403 with `needs_profile`, which the
* front end turns into the invite-redemption screen.
*/
import { eq } from 'drizzle-orm';
import type { Database } from '@pig/db';
import { apiKeys, teamMemberships, users } from '@pig/db';
import {
permissionGranted,
resolveReadPermissionGrants,
resolveWritePermissionGrants,
roleMeets,
TEAM_CAPABILITY_RULES,
type GlobalCapability,
type PermissionGrant,
type ReadCapability,
type Team,
type TeamCapability,
type TeamRole,
type WriteCapability,
} from '@pig/core';
import { createHash, timingSafeEqual } from 'node:crypto';
import type { Config } from './config';
import type { AuthProvider } from './auth-provider';
export interface Principal {
userId: string;
email: string;
name: string;
isPlatformAdmin: boolean;
teams: { team: Team; role: TeamRole }[];
/** How this request authenticated. Agents get their own audit trail. */
via: 'jwt' | 'api_key' | 'development';
apiKeyId?: string;
scopes: string[];
}
export class AuthError extends Error {
constructor(
message: string,
readonly status: 401 | 403,
readonly code: string,
) {
super(message);
this.name = 'AuthError';
}
}
export function createAuthenticator(
config: Config,
db: Database,
authProvider: AuthProvider | null,
) {
async function loadPrincipal(
userId: string,
via: Principal['via'],
extras: { apiKeyId?: string; scopes?: string[] } = {},
): Promise<Principal> {
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!user) throw new AuthError('No PIG profile for this account.', 403, 'needs_profile');
if (user.deactivatedAt) throw new AuthError('This account is deactivated.', 403, 'deactivated');
const memberships = await db
.select({ team: teamMemberships.team, role: teamMemberships.role })
.from(teamMemberships)
.where(eq(teamMemberships.userId, user.id));
return {
userId: user.id,
email: user.email,
name: user.name,
// Admin rights come from the database, but the environment allowlist can
// grant them too — that is how the first admin exists before anyone has
// been able to log in and promote anybody.
isPlatformAdmin:
user.isPlatformAdmin || config.adminEmails.includes(user.email.toLowerCase()),
teams: memberships as { team: Team; role: TeamRole }[],
via,
apiKeyId: extras.apiKeyId,
scopes: extras.scopes ?? ['read', 'write'],
};
}
return {
/**
* Resolve the principal for a request, or throw.
*
* Accepts either a Supabase JWT or a PIG API key, both in the
* Authorization header. API keys exist so that an agent acting for a person
* is a distinct principal from that person — separately revocable, with its
* own audit trail and its own scopes.
*/
async authenticate(header: string | undefined): Promise<Principal> {
const token = header?.startsWith('Bearer ') ? header.slice(7).trim() : null;
// An explicit PIG key must be honoured even in development. Otherwise a
// revoked or malformed key silently becomes the development user, which
// makes local integration tests pass without testing the credential at
// all and hides the exact failures developers need to see.
if (token?.startsWith('pig_')) return authenticateApiKey(token);
// Development escape hatch. Guarded three ways, and `loadConfig` refuses
// to start in production without identity configuration, so this cannot
// leak into a real deployment.
if (!authProvider && !config.isProduction) {
const [devUser] = await db.select().from(users).limit(1);
if (!devUser) {
throw new AuthError(
'Auth is disabled and the database has no users. Run `npm run db:seed`.',
403,
'no_dev_user',
);
}
return loadPrincipal(devUser.id, 'development');
}
if (!header?.startsWith('Bearer ')) {
throw new AuthError('Missing bearer token.', 401, 'no_token');
}
if (!authProvider) {
throw new AuthError('Authentication is not configured.', 401, 'no_jwks');
}
let subject: string;
try {
subject = (await authProvider.verifyAccessToken(token!)).subject;
} catch {
// Deliberately opaque: distinguishing "expired" from "malformed" from
// "wrong issuer" tells an attacker which knob to turn.
throw new AuthError('Invalid or expired token.', 401, 'invalid_token');
}
const [user] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.authSubject, subject))
.limit(1);
if (!user) {
// Authenticated but not authorized — the case that matters when the
// identity provider is shared with another application.
throw new AuthError(
'This account is not a member of this PIG workspace.',
403,
'needs_profile',
);
}
return loadPrincipal(user.id, 'jwt');
},
};
async function authenticateApiKey(token: string): Promise<Principal> {
const hash = hashApiKey(token);
const [record] = await db
.select()
.from(apiKeys)
.where(eq(apiKeys.keyHash, hash))
.limit(1);
if (!record) throw new AuthError('Unknown API key.', 401, 'invalid_key');
assertApiKeyActive(record);
// Best-effort last-used stamp. Never block the request on it: a failed
// bookkeeping write must not deny access.
void db
.update(apiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(apiKeys.id, record.id))
.catch(() => {});
return loadPrincipal(record.userId, 'api_key', {
apiKeyId: record.id,
scopes: record.scopes,
});
}
}
export function assertApiKeyActive(
record: { revokedAt: Date | null; expiresAt: Date | null },
now = new Date(),
): void {
if (record.revokedAt) throw new AuthError('This API key was revoked.', 401, 'revoked_key');
if (record.expiresAt && record.expiresAt < now) {
throw new AuthError('This API key has expired.', 401, 'expired_key');
}
}
/**
* Hash an API key for storage and lookup.
*
* SHA-256 without a salt is correct here, unlike for passwords: the key is 256
* bits of machine-generated randomness, so there is no dictionary to attack and
* lookup must be deterministic. What matters is that the plaintext is never
* stored, so a database leak yields no working credentials.
*/
export function hashApiKey(key: string): string {
return createHash('sha256').update(key).digest('hex');
}
/** Constant-time compare, for anywhere a secret is checked directly. */
export function safeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
// Length alone can leak, so compare hashes of equal length rather than
// returning early on a length mismatch.
const ah = createHash('sha256').update(ab).digest();
const bh = createHash('sha256').update(bb).digest();
return timingSafeEqual(ah, bh);
}
/** Does this principal belong to the team, at or above the given role? */
export function hasTeamAccess(
principal: Principal,
team: Team,
minimumRole: TeamRole = 'member',
): boolean {
if (principal.isPlatformAdmin) return true;
const membership = principal.teams.find((t) => t.team === team);
if (!membership) return false;
return roleMeets(membership.role, minimumRole);
}
export function requireScope(principal: Principal, scope: string): void {
if (principal.scopes.includes(scope)) return;
throw new AuthError(`This credential lacks the '${scope}' scope.`, 403, 'insufficient_scope');
}
/**
* Effective grants include credential scope, not merely the owner's roles.
*
* Read and write scopes are filtered separately. Before read capabilities
* existed a read-only key resolved to no grants at all, which was right then
* and would now be wrong: it would tell `/api/me` that a read-only agent may
* not read, and the browser would grey out a page the server happily serves.
*/
export function effectivePermissions(principal: Principal): PermissionGrant[] {
const grants: PermissionGrant[] = [];
if (principal.scopes.includes('read')) grants.push(...resolveReadPermissionGrants(principal));
if (principal.scopes.includes('write')) grants.push(...resolveWritePermissionGrants(principal));
return grants;
}
export function requireCapability(principal: Principal, capability: GlobalCapability): void;
export function requireCapability(
principal: Principal,
capability: TeamCapability,
team: Team,
): void;
export function requireCapability(
principal: Principal,
capability: WriteCapability,
team?: Team,
): void {
requireScope(principal, 'write');
if (permissionGranted(resolveWritePermissionGrants(principal), capability, team)) return;
throw new AuthError(
`This principal lacks the '${capability}' capability${team ? ` for ${team}` : ''}.`,
403,
'insufficient_permission',
);
}
/**
* "May they do this on *some* team?"
*
* Separate from `requireCapability` and deliberately harder to type by
* accident. Passing no team to the old `requireCapability` silently meant this
* — which is how a research-team admin could bulk-import demand deals — so the
* overloads above now refuse it and every remaining any-team check has to say
* so in its own name. Use it only where no team is knowable yet: listing the
* spreadsheets in someone's Drive, before an entity has been chosen. The
* moment the target is known, go back to `requireCapability` with its team.
*/
export function requireAnyTeamCapability(
principal: Principal,
capability: TeamCapability,
): void {
requireScope(principal, 'write');
const grants = resolveWritePermissionGrants(principal);
for (const team of TEAM_CAPABILITY_RULES[capability].teams) {
if (permissionGranted(grants, capability, team)) return;
}
throw new AuthError(
`This principal lacks the '${capability}' capability on any team.`,
403,
'insufficient_permission',
);
}
/**
* Reads are governed too. The 'read' scope is checked rather than 'write'
* because a read-only API key is exactly the credential this must admit.
*/
export function requireReadCapability(
principal: Principal,
capability: ReadCapability,
): void {
requireScope(principal, 'read');
if (permissionGranted(resolveReadPermissionGrants(principal), capability)) return;
throw new AuthError(
`This principal lacks the '${capability}' capability.`,
403,
'insufficient_permission',
);
}