Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion bim-review-coordinator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ POST /api/internal/review-sessions/{session_id}/stage-binding-confirmations
POST /api/governance/search/model/for-session/{session_id}
POST /api/governance/search/model/for-session/{session_id}/partial-confirmation
POST /api/governance/search/model/for-ifc-ready/{job_id}
POST /api/governance/issues/from-a4-search/for-session/{session_id}
```

The canonical A4 search route authenticates the caller first, requires the
Expand All @@ -101,7 +102,15 @@ lab-only `ifc_ready_table_only` compatibility route until user auth carries
tenant/project authorization; it never forwards a mapping or session proof
context.

Trusted A4 forwarding requires a non-empty server-only
The scoped A4 Issue route accepts one confirmed row/draft per request. It
reauthenticates the current session principal and primary lease, requires the
exact production model/artifact/binding and verified mapping capability, then
adds non-overridable trusted context before forwarding. Browser actor/source,
session, lease, proof-digest, and trusted-context fields are rejected. The
currently mounted local-dev lease remains `lab_unverified`, so mutation stays
fail-closed until an authentic shared lease capability is available.

Trusted A4 forwarding requires a 16–4096 character printable-ASCII server-only
`A4_INTERNAL_CONTEXT_TOKEN` shared with governance-service and either an exact
loopback `GOVERNANCE_API_BASE` or an exact origin listed by
`A4_TRUSTED_GOVERNANCE_ORIGINS`. The host-kit deployment injects only its
Expand Down
7 changes: 7 additions & 0 deletions bim-review-coordinator/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
type A4SearchPrincipalResolution,
type A4SearchSessionResolution as A4SearchRouteSessionResolution,
} from "./routes/a4SearchRoutes.js";
import { registerA4IssueRoutes } from "./routes/a4IssueRoutes.js";
import {
registerA4HandoffRoutes,
type A4SearchSessionResolution,
Expand Down Expand Up @@ -3820,6 +3821,12 @@ export function createCoordinatorApp(
resolveIfcReadyContext: resolveA4SearchIfcReadyContext,
});

registerA4IssueRoutes(app, {
isSafeSessionId,
authenticatePrincipal: authenticateA4SearchPrincipal,
resolveSessionContext: resolveA4SearchSessionContext,
});

registerGovernanceProxy(app, {
isSafeSessionId,
isSafeIfcReadyJobId,
Expand Down
349 changes: 349 additions & 0 deletions bim-review-coordinator/src/routes/a4IssueRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,349 @@
import type { Express, Request, Response } from "express";

import {
forwardTrustedA4,
type A4SearchPrincipal,
type A4SearchPrincipalResolution,
type A4SearchSessionContext,
type A4SearchSessionResolution,
} from "./a4SearchRoutes.js";

export interface A4IssueRouteDeps {
isSafeSessionId?: (sessionId: string) => boolean;
authenticatePrincipal?: (
headers: Record<string, string | undefined>,
) => A4SearchPrincipalResolution;
resolveSessionContext?: (
sessionId: string,
principal: A4SearchPrincipal,
) => A4SearchSessionResolution;
trustedGovernanceOrigins?: string[];
a4InternalContextToken?: string;
governanceTimeoutMs?: number;
}

type A4IssueDraft = {
title: string;
description?: string | null;
severity: "low" | "medium" | "high" | "critical";
assignee?: string | null;
ifc_guid: string;
usd_prim_path?: string | null;
evidence_proof: string;
a4_evidence_snapshot: Record<string, unknown>;
};

type SanitizedDraft =
| { ok: true; value: A4IssueDraft }
| { ok: false; authority: boolean; detail: string };

const ISSUE_DRAFT_KEYS = new Set([
"title",
"description",
"severity",
"assignee",
"ifc_guid",
"usd_prim_path",
"evidence_proof",
"a4_evidence_snapshot",
]);
const BROWSER_AUTHORITY_KEYS = new Set([
"user_id",
"actor",
"principal",
"principal_ref",
"session_id",
"review_session_id",
"source_type",
"source_ref",
"model_version_id",
"primary_artifact_id",
"active_binding_revision",
"mapping_provenance",
"primary_lease_capability",
"auth_scope",
"lease_id",
"lease_token",
"viewer_lease_id",
"viewer_lease_token",
"a4_trusted_context",
"proof_id",
"snapshot_hash",
"proof_digest",
"creation_request_hash",
]);
const BROWSER_AUTHORITY_HEADERS = new Set(["x-actor", "x-operator"]);
const PROOF_PATTERN = /^a4p\.[A-Za-z0-9_-]{1,64}\.[A-Za-z0-9_-]{16,96}\.[0-9a-f]{64}$/;
const IFC_GUID_PATTERN = /^[A-Za-z0-9_$-]{1,64}$/;
const USD_PRIM_PATTERN = /^\/(?:[A-Za-z_][A-Za-z0-9_]*)+(?:\/[A-Za-z_][A-Za-z0-9_]*)*$/;

function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

function normalizedRequestHeaders(request: Request): Record<string, string | undefined> {
const headers: Record<string, string | undefined> = {};
for (const [name, value] of Object.entries(request.headers)) {
headers[name.toLowerCase()] = Array.isArray(value) ? value[0] : value;
}
return headers;
}

function hasBrowserAuthorityHeader(headers: Record<string, string | undefined>): boolean {
return [...BROWSER_AUTHORITY_HEADERS].some((name) => {
const value = headers[name];
return typeof value === "string" && value.trim().length > 0;
});
}

function optionalText(
body: Record<string, unknown>,
key: "description" | "assignee" | "usd_prim_path",
maxLength: number,
): boolean {
const value = body[key];
return value === undefined || value === null || (typeof value === "string" && value.length <= maxLength);
}

function sanitizeIssueDraft(body: unknown): SanitizedDraft {
if (!isRecord(body)) {
return { ok: false, authority: false, detail: "A4 Issue draft must be a JSON object." };
}
const keys = Object.keys(body);
if (keys.some((key) => BROWSER_AUTHORITY_KEYS.has(key.toLowerCase()))) {
return { ok: false, authority: true, detail: "Browser identity or authority fields are not accepted." };
}
if (keys.some((key) => !ISSUE_DRAFT_KEYS.has(key))) {
return { ok: false, authority: false, detail: "A4 Issue draft contains unsupported fields." };
}
if (
typeof body.title !== "string"
|| body.title.trim().length === 0
|| body.title.length > 500
|| !optionalText(body, "description", 4_000)
|| !optionalText(body, "assignee", 256)
|| !optionalText(body, "usd_prim_path", 2_048)
|| (body.severity !== "low" && body.severity !== "medium"
&& body.severity !== "high" && body.severity !== "critical")
|| typeof body.ifc_guid !== "string"
|| !IFC_GUID_PATTERN.test(body.ifc_guid)
|| typeof body.evidence_proof !== "string"
|| !PROOF_PATTERN.test(body.evidence_proof)
|| !isRecord(body.a4_evidence_snapshot)
) {
return { ok: false, authority: false, detail: "A4 Issue draft is invalid." };
}
if (
typeof body.usd_prim_path === "string"
&& !USD_PRIM_PATTERN.test(body.usd_prim_path)
) {
return { ok: false, authority: false, detail: "A4 Issue USD prim path is invalid." };
}
return { ok: true, value: body as A4IssueDraft };
}

function authenticate(
request: Request,
response: Response,
deps: A4IssueRouteDeps,
): A4SearchPrincipal | null {
if (!deps.authenticatePrincipal) {
response.status(503).json({
error_code: "a4_authentication_unavailable",
detail: "A4 authentication is unavailable.",
});
return null;
}
const headers = normalizedRequestHeaders(request);
let resolution: A4SearchPrincipalResolution;
try {
resolution = deps.authenticatePrincipal(headers);
} catch {
response.status(503).json({
error_code: "a4_authentication_unavailable",
detail: "A4 authentication is unavailable.",
});
return null;
}
if (!resolution.ok) {
response.status(resolution.status).json({
error_code: resolution.error_code,
detail: resolution.detail,
});
return null;
}
if (
typeof resolution.principal.principal_ref !== "string"
|| resolution.principal.principal_ref.length === 0
|| resolution.principal.principal_ref.length > 160
|| (resolution.principal.auth_scope !== "production" && resolution.principal.auth_scope !== "lab")
) {
response.status(503).json({
error_code: "a4_authentication_unavailable",
detail: "A4 authentication is unavailable.",
});
return null;
}
if (hasBrowserAuthorityHeader(headers)) {
response.status(403).json({
error_code: "a4_browser_authority_forbidden",
detail: "Browser identity headers cannot establish A4 authority.",
});
return null;
}
return resolution.principal;
}

function resolveSession(
response: Response,
deps: A4IssueRouteDeps,
sessionId: string,
principal: A4SearchPrincipal,
): A4SearchSessionContext | null {
if (!deps.resolveSessionContext) {
response.status(503).json({
error_code: "a4_trusted_context_unavailable",
detail: "A4 session authorization is unavailable.",
});
return null;
}
let resolution: A4SearchSessionResolution;
try {
resolution = deps.resolveSessionContext(sessionId, principal);
} catch {
response.status(503).json({
error_code: "a4_trusted_context_unavailable",
detail: "A4 session authorization is unavailable.",
});
return null;
}
if (!resolution.ok) {
response.status(resolution.status).json({
error_code: resolution.error_code,
detail: resolution.detail,
});
return null;
}
return resolution.context;
}

function trustedIssueContext(
sessionId: string,
principal: A4SearchPrincipal,
context: A4SearchSessionContext,
): Record<string, unknown> | null {
if (
principal.auth_scope !== "production"
|| context.review_session_id !== sessionId
|| !context.model_version_id
|| !context.primary_artifact_id
|| !context.active_binding_revision
|| context.mapping_provenance !== "server_resolved"
|| !context.element_mapping_path
|| context.primary_lease_capability !== "verified"
) return null;
return {
scope: "session_table_only",
review_session_id: sessionId,
principal_ref: principal.principal_ref,
primary_artifact_id: context.primary_artifact_id,
active_binding_revision: context.active_binding_revision,
model_version_id: context.model_version_id,
auth_scope: "production",
mapping_provenance: "server_resolved",
primary_lease_capability: "verified",
};
}

function snapshotMatchesCurrentBinding(
draft: A4IssueDraft,
trusted: Record<string, unknown>,
): boolean {
const snapshot = draft.a4_evidence_snapshot;
const binding = snapshot.session_binding;
const row = snapshot.row;
if (!isRecord(binding) || !isRecord(row)) return false;
const expected = {
review_session_id: trusted.review_session_id,
principal_ref: trusted.principal_ref,
primary_artifact_id: trusted.primary_artifact_id,
active_binding_revision: trusted.active_binding_revision,
model_version_id: trusted.model_version_id,
mapping_provenance: "server_resolved",
primary_lease_capability: "verified",
auth_scope: "production",
session_id: trusted.review_session_id,
principal: trusted.principal_ref,
model_artifact: trusted.primary_artifact_id,
};
if (
snapshot.model_version_id !== trusted.model_version_id
|| Object.entries(expected).some(([key, value]) => binding[key] !== value)
|| row.ifc_guid !== draft.ifc_guid
) return false;
const acceptedPrim = row.accepted_usd_prim;
const snapshotPrim = row.usd_prim_path;
const draftPrim = draft.usd_prim_path ?? null;
return (acceptedPrim ?? null) === draftPrim && (snapshotPrim ?? null) === draftPrim;
}

export function registerA4IssueRoutes(app: Express, deps: A4IssueRouteDeps): void {
app.post("/api/governance/issues/from-a4-search/for-session/:sessionId", (request, response) => {
const sessionId = request.params.sessionId;
if (!deps.isSafeSessionId?.(sessionId)) {
response.status(400).json({ error_code: "invalid_session_id", detail: "Invalid review session id." });
return;
}
const principal = authenticate(request, response, deps);
if (!principal) return;
if (principal.auth_scope !== "production") {
response.status(503).json({
error_code: "a4_issue_authority_unavailable",
detail: "Production A4 Issue authority is unavailable.",
});
return;
}
const draft = sanitizeIssueDraft(request.body);
if (!draft.ok) {
response.status(draft.authority ? 403 : 400).json({
error_code: draft.authority ? "a4_browser_authority_forbidden" : "invalid_a4_issue_draft",
detail: draft.detail,
});
return;
}
const context = resolveSession(response, deps, sessionId, principal);
if (!context) return;
const trusted = trustedIssueContext(sessionId, principal, context);
if (!trusted) {
response.status(503).json({
error_code: "a4_issue_authority_unavailable",
detail: "Current A4 Issue session authority is unavailable.",
});
return;
}
if (!snapshotMatchesCurrentBinding(draft.value, trusted)) {
response.status(403).json({
error_code: "a4_issue_binding_mismatch",
detail: "A4 Issue evidence does not match the current authorized session.",
});
return;
}
void forwardTrustedA4(
response,
deps,
"/api/internal/a4/issues/from-search",
"deterministic",
{ ...draft.value, a4_trusted_context: trusted },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recheck the session binding after upstream create

If the primary lease or active stage binding changes after resolveSession/snapshotMatchesCurrentBinding but before the governance call commits, this forwards the stale trusted object and governance has no way to know it is no longer current. In that race, an A4 Issue can be persisted for a proof tied to a previous artifact/revision/principal authorization; re-resolve and compare the binding after the upstream verification window, or make the create conditional on a fresh current binding.

AGENTS.md reference: bim-review-coordinator/AGENTS.md:L18-L19

Useful? React with 👍 / 👎.

[context.ifc_source_path, context.element_mapping_path ?? ""],
[
draft.value.title.normalize("NFC").trim(),
...(draft.value.description
? [draft.value.description.normalize("NFC")]
: []),
...(draft.value.assignee
? [draft.value.assignee.normalize("NFC").trim()]
: []),
],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
});
}
Loading
Loading