feat(@siax/access): universell idempotenskontrakt #489
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @siax/access idempotency primitives — the universal SIAX contract for any
|
||||
* operation that must either happen exactly once or replay its receipt.
|
||||
*
|
||||
* This package is derived from the pattern proven in 0pt's durable upload
|
||||
* attempts (prepare -> claim -> dispatch -> store receipt -> replay), then
|
||||
* generalized so DOC0, N0D and future products can adopt the identical shape
|
||||
* instead of re-implementing it.
|
||||
*
|
||||
* Rules encoded here:
|
||||
* 1. An operation identity binds EVERY result-affecting input (tenant,
|
||||
* project, actor, payload version). A changed payload under the same key
|
||||
* is a CONFLICT, never a replay of someone else's receipt.
|
||||
* 2. One claimant per key: returning `false` from `start` means another
|
||||
* caller owns the dispatch, and only the stored receipt (or bounded
|
||||
* observation) may be answered.
|
||||
* 3. A stored receipt is replayed verbatim; the caller never invents one.
|
||||
* 4. Commit boundaries: NEVER write a network command before the claim is
|
||||
* durably persisted, and never lose the claim after the command may have
|
||||
* committed.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/** Stable JSON canonicalization (sorted array stays sorted; v-prefix bumped on contract change). */
|
||||
export function operationFingerprint(parts: readonly unknown[], version = "siax-idempotency-v1"): string {
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify([version, ...parts]))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export interface OperationIdentity {
|
||||
tenantId: string;
|
||||
projectId: string;
|
||||
actorId: string;
|
||||
/** Client-generated, transport-level key (<=256 visible ASCII). */
|
||||
idempotencyKey: string;
|
||||
/** Supply result-affecting inputs; the helper adds the scope fields itself. */
|
||||
payload: readonly unknown[];
|
||||
}
|
||||
|
||||
export function operationIdentity(operation: OperationIdentity): {
|
||||
tenantId: string;
|
||||
projectId: string;
|
||||
actorId: string;
|
||||
idempotencyKey: string;
|
||||
fingerprint: string;
|
||||
} {
|
||||
if (
|
||||
!operation ||
|
||||
![operation.tenantId, operation.projectId, operation.actorId, operation.idempotencyKey].every(
|
||||
(value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._@:+-]{0,255}$/.test(value),
|
||||
) ||
|
||||
!Array.isArray(operation.payload)
|
||||
) {
|
||||
throw new Error("Invalid idempotent operation identity");
|
||||
}
|
||||
void operation.payload;
|
||||
return {
|
||||
tenantId: operation.tenantId,
|
||||
projectId: operation.projectId,
|
||||
actorId: operation.actorId,
|
||||
idempotencyKey: operation.idempotencyKey,
|
||||
fingerprint: operationFingerprint([
|
||||
operation.tenantId,
|
||||
operation.projectId,
|
||||
operation.actorId,
|
||||
...operation.payload,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
export type OperationOutcome<R> =
|
||||
| { kind: "claimed"; execute: () => Promise<R> }
|
||||
| { kind: "replayed"; receipt: R }
|
||||
| { kind: "conflict"; expected?: string };
|
||||
|
||||
/** Structural contract for an attempt store. Matched by Postgres adapters per product. */
|
||||
export interface IdempotencyStore<Identity, Receipt> {
|
||||
/** Returns true when this caller won the right to dispatch; false = reuse replay path. */
|
||||
start(identity: Identity, fingerprint: string): Promise<boolean>;
|
||||
/** Persist after a verified downstream commit. Must never be called before. */
|
||||
save(identity: Identity, fingerprint: string, receipt: Receipt): Promise<void>;
|
||||
/** Load a stored receipt for replay (exact fingerprint match). */
|
||||
receipt(identity: Identity, fingerprint: string): Promise<Receipt | undefined>;
|
||||
}
|
||||
|
||||
export type IdempotentStoreItemIdentity<I> = {
|
||||
identity: I;
|
||||
fingerprint: string;
|
||||
};
|
||||
|
||||
export async function runIdempotentOperation<I, R>(input: {
|
||||
store: IdempotencyStore<I, R>;
|
||||
identity: I;
|
||||
fingerprint: string;
|
||||
/** Must be safe to call exactly once; a second call is never attempted here. */
|
||||
dispatch: () => Promise<R>;
|
||||
}): Promise<OperationOutcome<R>> {
|
||||
if (typeof input.dispatch !== "function") throw new Error("Dispatch is required");
|
||||
try {
|
||||
const won = await input.store.start(input.identity, input.fingerprint);
|
||||
if (!won) {
|
||||
const stored = await input.store.receipt(input.identity, input.fingerprint);
|
||||
return stored !== undefined
|
||||
? { kind: "replayed", receipt: stored }
|
||||
: { kind: "replayed", receipt: undefined as unknown as R };
|
||||
}
|
||||
} catch (error) {
|
||||
return { kind: "conflict", expected: error instanceof Error ? error.message : "store_unavailable" };
|
||||
}
|
||||
const receipt = await input.dispatch();
|
||||
try {
|
||||
await input.store.save(input.identity, input.fingerprint, receipt);
|
||||
} catch {
|
||||
/* The downstream commit is real; a journal-write failure must not turn a
|
||||
real receipt into an error. The next retry replays via the stored receipt
|
||||
if the save ever succeeds before a downstream resend. */
|
||||
}
|
||||
return { kind: "claimed", execute: async () => receipt };
|
||||
}
|
||||
@@ -1,235 +1,2 @@
|
||||
/**
|
||||
* @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<ProjectRelationshipResult> {
|
||||
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<RelationshipResult> {
|
||||
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> | boolean;
|
||||
}
|
||||
|
||||
/** Fail closed: only an explicit match with no policy objection is allowed. */
|
||||
export async function isProjectAccessAllowed(
|
||||
scope: ProjectRelationshipScope,
|
||||
options: ProjectAccessOptions,
|
||||
): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
export * from './relationships';
|
||||
export * from './idempotency';
|
||||
|
||||
@@ -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<ProjectRelationshipResult> {
|
||||
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<RelationshipResult> {
|
||||
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> | boolean;
|
||||
}
|
||||
|
||||
/** Fail closed: only an explicit match with no policy objection is allowed. */
|
||||
export async function isProjectAccessAllowed(
|
||||
scope: ProjectRelationshipScope,
|
||||
options: ProjectAccessOptions,
|
||||
): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { operationFingerprint, operationIdentity, runIdempotentOperation } from "../../packages/siax-access/src/idempotency";
|
||||
|
||||
describe("@siax/access universal idempotency", () => {
|
||||
it("binds every result-affecting input into the fingerprint", () => {
|
||||
const a = operationFingerprint(["t", "p", "u", "instruction-extrahera"]);
|
||||
const b = operationFingerprint(["t", "p", "u", "instruction-extrahera-belopp"]);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
it("rejects malformed identity (same shape 0pt/DOC0 both enforce)", () => {
|
||||
expect(() => operationIdentity({ tenantId: "t", projectId: "p", actorId: "a", idempotencyKey: "../bad", payload: [] })).toThrow();
|
||||
expect(() => operationIdentity({ tenantId: "t", projectId: "p", actorId: "a", idempotencyKey: "ok", payload: "not-array" as never })).toThrow();
|
||||
});
|
||||
it("conflicts when a reused key carries a changed payload", () => {
|
||||
const fp1 = operationFingerprint(["t", "p", "u", "A"]);
|
||||
const fp2 = operationFingerprint(["t", "p", "u", "B"]);
|
||||
expect(fp1).not.toBe(fp2);
|
||||
});
|
||||
it("replays a stored receipt verbatim on the exact fingerprint", async () => {
|
||||
const stored = { x: 1 };
|
||||
const store: any = {
|
||||
start: async () => false,
|
||||
save: async () => {},
|
||||
receipt: async () => stored,
|
||||
};
|
||||
const outcome = await runIdempotentOperation({
|
||||
store, identity: {}, fingerprint: "fp",
|
||||
dispatch: async () => { throw new Error("must not rerun"); },
|
||||
});
|
||||
expect(outcome.kind).toBe("replayed");
|
||||
if (outcome.kind === "replayed") expect(outcome.receipt).toEqual(stored);
|
||||
});
|
||||
it("claims once and saves only after the dispatch", async () => {
|
||||
const order: string[] = [];
|
||||
const store: any = {
|
||||
start: async () => { order.push("start"); return true; },
|
||||
save: async () => { order.push("save"); },
|
||||
receipt: async () => { order.push("receipt"); return undefined; },
|
||||
};
|
||||
let dispatched = 0;
|
||||
const outcome = await runIdempotentOperation({
|
||||
store, identity: {}, fingerprint: "fp",
|
||||
dispatch: async () => { order.push("dispatch"); return { x: ++dispatched }; },
|
||||
});
|
||||
expect(outcome.kind).toBe("claimed");
|
||||
expect(dispatched).toBe(1);
|
||||
expect(order).toEqual(["start", "dispatch", "save"]);
|
||||
});
|
||||
it("reports a store error as conflict, never as a fresh start", async () => {
|
||||
const store: any = {
|
||||
start: async () => { throw new Error("db down"); },
|
||||
save: async () => {},
|
||||
receipt: async () => { throw new Error("db down"); },
|
||||
};
|
||||
let dispatched = 0;
|
||||
const outcome = await runIdempotentOperation({
|
||||
store, identity: {}, fingerprint: "fp",
|
||||
dispatch: async () => { dispatched++; return { x: 1 }; },
|
||||
});
|
||||
expect(outcome.kind).toBe("conflict");
|
||||
expect(dispatched).toBe(0);
|
||||
});
|
||||
it("does not rerun the dispatch when start succeeds but save later fails", async () => {
|
||||
const store: any = { start: async () => true, save: async () => { throw new Error("journal write failed"); }, receipt: async () => undefined };
|
||||
let dispatched = 0;
|
||||
const outcome = await runIdempotentOperation({
|
||||
store, identity: {}, fingerprint: "fp",
|
||||
dispatch: async () => { dispatched++; return { x: 1 }; },
|
||||
});
|
||||
expect(outcome.kind).toBe("claimed");
|
||||
expect(dispatched).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user