CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 6s
masterplan-lock / check (push) Successful in 6s
CI (SIAX Cloud) / security (push) Successful in 14s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 19s
CI (SIAX Cloud) / security (pull_request) Successful in 21s
Security Scan (SIAX Cloud) / security (push) Successful in 25s
CI (SIAX Cloud) / integration (pull_request) Successful in 29s
CI (SIAX Cloud) / quality (pull_request) Successful in 51s
CI (SIAX Cloud) / quality (push) Successful in 1m29s
- createInf0Gateway prefers the live OpenAI-compatible LiteLLM gateway and falls back to the native INF0 surface; admission stays local and fail-closed. - Project authorization: OpenFGA when configured, otherwise a validated server-owned OPT_PROJECT_GRANTS list (deny by default). - Tests for the gateway adapter, the factory and grants.
108 lines
3.4 KiB
TypeScript
108 lines
3.4 KiB
TypeScript
/**
|
|
* 0pt project authorization for the document workbench.
|
|
*
|
|
* Two sources, in order:
|
|
*
|
|
* 1. OpenFGA via the universal `@siax/access` primitive (the same one DOC0
|
|
* uses) when `OPENFGA_API_URL/STORE_ID/MODEL_ID/AUTH_TOKEN` are set.
|
|
* 2. An explicit, server-owned grant list (`OPT_PROJECT_GRANTS`), a
|
|
* transitional source for environments where the central relationship
|
|
* store is not deployed yet.
|
|
*
|
|
* FAIL CLOSED: when neither source is configured, or the source is
|
|
* unavailable, the answer is "no". A grant is never derived from a request
|
|
* header or body.
|
|
*
|
|
* `OPT_PROJECT_GRANTS` shape (JSON array):
|
|
* [{"tenantId":"379976258701823235","projectId":"prj_alpha","actorId":"*"}]
|
|
* `actorId: "*"` grants every verified actor in the tenant; otherwise the exact
|
|
* actor must match.
|
|
*/
|
|
import { OpenFgaRelationships, isProjectAccessAllowed } from '@siax/access';
|
|
import { readDocumentWorkbenchRuntime } from './runtime-config';
|
|
|
|
export interface ProjectScope {
|
|
tenantId: string;
|
|
projectId: string;
|
|
actorId: string;
|
|
}
|
|
|
|
export type ProjectAuthorizer = (scope: ProjectScope) => Promise<boolean>;
|
|
|
|
interface ProjectGrant {
|
|
tenantId: string;
|
|
projectId: string;
|
|
actorId: string;
|
|
}
|
|
|
|
const scopeId = /^[\x21-\x7e]{1,256}$/;
|
|
|
|
export function parseProjectGrants(raw: string | undefined): ProjectGrant[] | undefined {
|
|
if (!raw || !raw.trim()) return undefined;
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
if (!Array.isArray(parsed) || parsed.length === 0 || parsed.length > 1000) {
|
|
return undefined;
|
|
}
|
|
const grants: ProjectGrant[] = [];
|
|
for (const entry of parsed) {
|
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return undefined;
|
|
const grant = entry as Record<string, unknown>;
|
|
if (
|
|
typeof grant.tenantId !== 'string' ||
|
|
!scopeId.test(grant.tenantId) ||
|
|
typeof grant.projectId !== 'string' ||
|
|
!scopeId.test(grant.projectId) ||
|
|
typeof grant.actorId !== 'string' ||
|
|
(grant.actorId !== '*' && !scopeId.test(grant.actorId))
|
|
) {
|
|
return undefined;
|
|
}
|
|
grants.push({
|
|
tenantId: grant.tenantId,
|
|
projectId: grant.projectId,
|
|
actorId: grant.actorId,
|
|
});
|
|
}
|
|
return grants;
|
|
}
|
|
|
|
export function createProjectAuthorizer(env: NodeJS.ProcessEnv = process.env): ProjectAuthorizer {
|
|
const runtime = readDocumentWorkbenchRuntime(env);
|
|
let relationships: OpenFgaRelationships | undefined;
|
|
if (runtime.openfga) {
|
|
try {
|
|
relationships = new OpenFgaRelationships(runtime.openfga);
|
|
} catch {
|
|
relationships = undefined;
|
|
}
|
|
}
|
|
const grants = parseProjectGrants(env.OPT_PROJECT_GRANTS);
|
|
|
|
return async (scope) => {
|
|
if (!scopeId.test(scope.tenantId) || !scopeId.test(scope.projectId) || !scopeId.test(scope.actorId)) {
|
|
return false;
|
|
}
|
|
if (relationships) {
|
|
return isProjectAccessAllowed(scope, { relationships });
|
|
}
|
|
if (!grants) return false;
|
|
return grants.some(
|
|
(grant) =>
|
|
grant.tenantId === scope.tenantId &&
|
|
grant.projectId === scope.projectId &&
|
|
(grant.actorId === '*' || grant.actorId === scope.actorId),
|
|
);
|
|
};
|
|
}
|
|
|
|
/** True when the server has enough configuration to authorize at all. */
|
|
export function projectAuthorizationConfigured(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
if (readDocumentWorkbenchRuntime(env).openfga) return true;
|
|
return Boolean(parseProjectGrants(env.OPT_PROJECT_GRANTS));
|
|
}
|