diff --git a/packages/siax-access/src/index.ts b/packages/siax-access/src/index.ts index e2eae2e..739f633 100644 --- a/packages/siax-access/src/index.ts +++ b/packages/siax-access/src/index.ts @@ -1,2 +1,2 @@ -export * from "./relationships"; -export * from "./idempotency"; +export * from './relationships'; +export * from './idempotency'; diff --git a/packages/siax-access/src/relationships.ts b/packages/siax-access/src/relationships.ts new file mode 100644 index 0000000..6a2bcc8 --- /dev/null +++ b/packages/siax-access/src/relationships.ts @@ -0,0 +1,235 @@ +/** + * @siax/access — universal project/tenant relationship checks for SIAX Cloud. + * + * This is the reusable extraction of the pattern first proven in DOC0's + * `src/clients/project-relationships.ts`. It is deliberately framework-neutral + * (no Next.js, Fastify, Prisma or product types) so every SIAX product can + * consume the exact same authorization primitive instead of copying it: + * + * 0pt — document upload + structuring scope + * DOC0 — document/version/signature scope + * N0D — job submission scope + * any app — tenant/project membership + * + * IMPORTANT: a relationship match is ONE input to authorization, never a + * complete decision. The caller must independently bind tenant and actor to a + * verified principal (never to request headers), enforce tenant/project + * status, capability, data-handling policy and an authoritative tuple + * lifecycle. `resolveRelationship` fails closed: only an explicit + * `relationships_match` is treated as allowed. + */ + +export type RelationshipResult = 'allowed' | 'denied' | 'unavailable'; + +/** The outcome of the composite project check. */ +export type ProjectRelationshipResult = 'relationships_match' | 'denied' | 'unavailable'; + +export interface ProjectRelationshipScope { + tenantId: string; + projectId: string; + actorId: string; +} + +const objectId = /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/; +const ulid = /^[0-9A-HJKMNP-TV-Z]{26}$/; +const maxResponseBytes = 16 * 1024; + +export interface OpenFgaRelationshipsOptions { + baseUrl: string; + storeId: string; + modelId: string; + serviceToken: string; + timeoutMs?: number; + fetchImpl?: typeof fetch; + /** Explicit test-only opt-in for an isolated loopback OpenFGA process. */ + testOnlyAllowLoopbackHttp?: boolean; +} + +/** + * Read-only OpenFGA relationship reader. Never writes tuples, never caches, + * and never retries: an unavailable authorization source must stay visible to + * the caller so it can fail closed. + */ +export class OpenFgaRelationships { + private readonly baseUrl: string; + private readonly fetchImpl: typeof fetch; + private readonly storeId: string; + private readonly modelId: string; + private readonly serviceToken: string; + private readonly timeoutMs: number; + + constructor(options: OpenFgaRelationshipsOptions) { + const url = new URL(options.baseUrl); + const loopbackTest = + process.env.NODE_ENV === 'test' && + options.testOnlyAllowLoopbackHttp === true && + url.protocol === 'http:' && + url.hostname === '127.0.0.1'; + if ( + (!loopbackTest && url.protocol !== 'https:') || + url.username || + url.password || + url.search || + url.hash || + typeof options.storeId !== 'string' || + typeof options.modelId !== 'string' || + !ulid.test(options.storeId) || + !ulid.test(options.modelId) || + typeof options.serviceToken !== 'string' || + !/^[\x21-\x7e]{1,8192}$/.test(options.serviceToken) || + (options.timeoutMs !== undefined && + (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 100 || options.timeoutMs > 30_000)) + ) { + throw new Error('Invalid trusted OpenFGA configuration'); + } + this.baseUrl = url.toString().replace(/\/+$/, ''); + this.fetchImpl = options.fetchImpl ?? fetch; + this.storeId = options.storeId; + this.modelId = options.modelId; + this.serviceToken = options.serviceToken; + this.timeoutMs = options.timeoutMs ?? 5_000; + } + + /** + * Both checks are necessary: project membership alone may inherit from a + * different parent tenant if bad tuples associate a project with two + * tenants. Returns `relationships_match` only when both hold. + */ + async checkProject(scope: ProjectRelationshipScope): Promise { + if (!scope || typeof scope !== 'object' || Array.isArray(scope)) { + return 'denied'; + } + const { tenantId, projectId, actorId } = scope; + if (![tenantId, projectId, actorId].every((value) => typeof value === 'string' && objectId.test(value))) { + return 'denied'; + } + const actor = await this.checkTuple(`user:${actorId}`, 'member', `tenant:${tenantId}`); + if (actor !== 'allowed') return actor === 'denied' ? 'denied' : 'unavailable'; + const parent = await this.checkTuple(`tenant:${tenantId}`, 'tenant', `project:${projectId}`); + return parent === 'allowed' ? 'relationships_match' : parent; + } + + /** One raw relationship check. Public so products can map their own tuples. */ + async checkTuple(user: string, relation: string, object: string): Promise { + if ( + ![user, relation, object].every( + (value) => typeof value === 'string' && value.length > 0 && value.length <= 256, + ) + ) { + return 'denied'; + } + try { + const response = await this.fetchImpl(`${this.baseUrl}/stores/${this.storeId}/check`, { + method: 'POST', + headers: { + authorization: `Bearer ${this.serviceToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + authorization_model_id: this.modelId, + consistency: 'HIGHER_CONSISTENCY', + tuple_key: { user, relation, object }, + }), + redirect: 'error', + signal: AbortSignal.timeout(this.timeoutMs), + }); + if (!response.ok || !response.body) return 'unavailable'; + const reader = response.body.getReader(); + try { + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const part = await reader.read(); + if (part.done) break; + total += part.value.byteLength; + if (total > maxResponseBytes) { + void reader.cancel().catch(() => undefined); + return 'unavailable'; + } + chunks.push(part.value); + } + const result: unknown = JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)), + ); + if ( + !result || + typeof result !== 'object' || + Array.isArray(result) || + typeof (result as { allowed?: unknown }).allowed !== 'boolean' + ) { + return 'unavailable'; + } + return (result as { allowed: boolean }).allowed ? 'allowed' : 'denied'; + } finally { + reader.releaseLock(); + } + } catch { + return 'unavailable'; + } + } +} + +/** + * Canonical environment variable names used across the fleet. Reads are + * explicit (no implicit defaults) so a missing value is visible. + */ +export const OPENFGA_ENV = { + baseUrl: 'OPENFGA_API_URL', + storeId: 'OPENFGA_STORE_ID', + modelId: 'OPENFGA_MODEL_ID', + serviceToken: 'OPENFGA_AUTH_TOKEN', +} as const; + +export interface ProjectAccessOptions { + /** The already-configured relationship reader, or undefined when OpenFGA is not configured. */ + relationships?: OpenFgaRelationships; + /** + * Optional extra policy (project status, capability, data-handling). It runs + * only after the relationship check matched; a rejection is final. + */ + additionalPolicy?: (scope: ProjectRelationshipScope) => Promise | boolean; +} + +/** Fail closed: only an explicit match with no policy objection is allowed. */ +export async function isProjectAccessAllowed( + scope: ProjectRelationshipScope, + options: ProjectAccessOptions, +): Promise { + if (!options.relationships) return false; + const result = await options.relationships.checkProject(scope); + if (result !== 'relationships_match') return false; + if (options.additionalPolicy) { + try { + return (await options.additionalPolicy(scope)) === true; + } catch { + return false; + } + } + return true; +} + +/** + * Build a relationship reader from a `NodeJS.ProcessEnv`-shaped object. + * Returns `undefined` (fail closed) when any value is missing or invalid. + */ +export function openFgaFromEnv( + env: NodeJS.ProcessEnv = process.env, + fetchImpl?: typeof fetch, +): OpenFgaRelationships | undefined { + const baseUrl = env[OPENFGA_ENV.baseUrl]; + const storeId = env[OPENFGA_ENV.storeId]; + const modelId = env[OPENFGA_ENV.modelId]; + const serviceToken = env[OPENFGA_ENV.serviceToken]; + if (!baseUrl || !storeId || !modelId || !serviceToken) return undefined; + try { + return new OpenFgaRelationships({ + baseUrl, + storeId, + modelId, + serviceToken, + ...(fetchImpl ? { fetchImpl } : {}), + }); + } catch { + return undefined; + } +}