13dec6b4b8
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>
325 lines
11 KiB
TypeScript
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',
|
|
);
|
|
}
|