release: WRNexusJS 0.8.0
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# @wrnexus/identity
|
||||
|
||||
Enterprise identity and governance for WRNexusJS: OIDC discovery, signed SAML adapter flows,
|
||||
LDAP/Active Directory synchronization adapters, SCIM provisioning, scoped API keys, service
|
||||
accounts, approval workflows, consent history, retention, subject export/deletion and audit.
|
||||
|
||||
The package complements `@wrnexus/auth` (passkeys, MFA, devices, sessions, OAuth and audited
|
||||
impersonation) and `@wrnexus/authz` (RBAC, ABAC and policy decisions). Protocol-specific SAML and
|
||||
directory parsing is supplied through adapters so applications can select a maintained vendor SDK
|
||||
without weakening framework validation, replay protection or governance auditing.
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@wrnexus/identity",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"description": "Enterprise federation, provisioning, machine identity, and privacy governance for WRNexusJS.",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/auth": "workspace:*",
|
||||
"@wrnexus/authz": "workspace:*",
|
||||
"@wrnexus/oauth": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
export interface OidcMetadata {
|
||||
issuer: string;
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
userinfo_endpoint?: string;
|
||||
jwks_uri: string;
|
||||
scopes_supported?: string[];
|
||||
}
|
||||
|
||||
function secureUrl(value: string, field: string): URL {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" && url.hostname !== "localhost")
|
||||
throw new Error(`WRN-IDENTITY-URL: ${field} must use HTTPS.`);
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function discoverOidc(
|
||||
issuer: string,
|
||||
options: { fetch?: typeof fetch } = {},
|
||||
): Promise<OidcMetadata> {
|
||||
const expected = secureUrl(issuer, "issuer");
|
||||
const endpoint = new URL(
|
||||
".well-known/openid-configuration",
|
||||
`${expected.href.replace(/\/$/, "")}/`,
|
||||
);
|
||||
const response = await (options.fetch ?? fetch)(endpoint, {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) throw new Error(`WRN-OIDC-DISCOVERY: provider returned ${response.status}.`);
|
||||
const metadata = (await response.json()) as OidcMetadata;
|
||||
if (metadata.issuer.replace(/\/$/, "") !== expected.href.replace(/\/$/, ""))
|
||||
throw new Error("WRN-OIDC-ISSUER: discovered issuer does not match configuration.");
|
||||
for (const field of ["authorization_endpoint", "token_endpoint", "jwks_uri"] as const)
|
||||
secureUrl(metadata[field], field);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function oidcAuthorizationUrl(
|
||||
metadata: OidcMetadata,
|
||||
input: {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
state: string;
|
||||
nonce: string;
|
||||
codeChallenge: string;
|
||||
scopes?: string[];
|
||||
},
|
||||
): string {
|
||||
const url = secureUrl(metadata.authorization_endpoint, "authorization_endpoint");
|
||||
const values = {
|
||||
response_type: "code",
|
||||
client_id: input.clientId,
|
||||
redirect_uri: input.redirectUri,
|
||||
scope: (input.scopes ?? ["openid", "profile", "email"]).join(" "),
|
||||
state: input.state,
|
||||
nonce: input.nonce,
|
||||
code_challenge: input.codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
};
|
||||
for (const [key, value] of Object.entries(values)) url.searchParams.set(key, value);
|
||||
return url.href;
|
||||
}
|
||||
|
||||
export interface EnterpriseIdentity {
|
||||
externalId: string;
|
||||
username: string;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
groups: string[];
|
||||
active: boolean;
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SamlAssertion {
|
||||
id: string;
|
||||
issuer: string;
|
||||
audience: string;
|
||||
recipient: string;
|
||||
expiresAt: number;
|
||||
identity: EnterpriseIdentity;
|
||||
}
|
||||
export interface SamlAdapter {
|
||||
createLoginRequest(input: {
|
||||
requestId: string;
|
||||
callbackUrl: string;
|
||||
relayState: string;
|
||||
}): Promise<string> | string;
|
||||
verifySignedResponse(response: string): Promise<SamlAssertion>;
|
||||
}
|
||||
export interface ReplayStore {
|
||||
consume(id: string, expiresAt: number): Promise<boolean>;
|
||||
}
|
||||
export function memoryReplayStore(now: () => number = Date.now): ReplayStore {
|
||||
const ids = new Map<string, number>();
|
||||
return {
|
||||
async consume(id, expiresAt) {
|
||||
for (const [key, expiry] of ids) if (expiry <= now()) ids.delete(key);
|
||||
if (ids.has(id)) return false;
|
||||
ids.set(id, expiresAt);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
export function createSamlFederation(options: {
|
||||
adapter: SamlAdapter;
|
||||
issuer: string;
|
||||
audience: string;
|
||||
recipient: string;
|
||||
replayStore?: ReplayStore;
|
||||
now?: () => number;
|
||||
}) {
|
||||
const replay = options.replayStore ?? memoryReplayStore(options.now);
|
||||
const now = options.now ?? Date.now;
|
||||
return {
|
||||
login: options.adapter.createLoginRequest.bind(options.adapter),
|
||||
async callback(encodedResponse: string): Promise<EnterpriseIdentity> {
|
||||
const assertion = await options.adapter.verifySignedResponse(encodedResponse);
|
||||
if (
|
||||
assertion.issuer !== options.issuer ||
|
||||
assertion.audience !== options.audience ||
|
||||
assertion.recipient !== options.recipient
|
||||
)
|
||||
throw new Error("WRN-SAML-BOUNDARY: issuer, audience, or recipient mismatch.");
|
||||
if (assertion.expiresAt <= now()) throw new Error("WRN-SAML-EXPIRED: assertion has expired.");
|
||||
if (!(await replay.consume(assertion.id, assertion.expiresAt)))
|
||||
throw new Error("WRN-SAML-REPLAY: assertion was already consumed.");
|
||||
return assertion.identity;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface DirectoryAdapter {
|
||||
kind: "ldap" | "active-directory";
|
||||
search(input: {
|
||||
baseDn: string;
|
||||
filter: string;
|
||||
attributes: string[];
|
||||
signal?: AbortSignal;
|
||||
}): Promise<EnterpriseIdentity[]>;
|
||||
authenticate?(
|
||||
username: string,
|
||||
password: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EnterpriseIdentity | null>;
|
||||
}
|
||||
export async function syncDirectory(
|
||||
adapter: DirectoryAdapter,
|
||||
options: {
|
||||
baseDn: string;
|
||||
filter?: string;
|
||||
attributes?: string[];
|
||||
signal?: AbortSignal;
|
||||
upsert: (identity: EnterpriseIdentity) => void | Promise<void>;
|
||||
disableMissing?: (externalIds: string[]) => void | Promise<void>;
|
||||
},
|
||||
) {
|
||||
const identities = await adapter.search({
|
||||
baseDn: options.baseDn,
|
||||
filter: options.filter ?? "(objectClass=person)",
|
||||
attributes: options.attributes ?? ["uid", "mail", "displayName", "memberOf"],
|
||||
signal: options.signal,
|
||||
});
|
||||
for (const identity of identities) await options.upsert(identity);
|
||||
await options.disableMissing?.(identities.map((identity) => identity.externalId));
|
||||
return { provider: adapter.kind, synchronized: identities.length };
|
||||
}
|
||||
|
||||
export interface ScimUser extends EnterpriseIdentity {
|
||||
id: string;
|
||||
/** RFC 7643 field accepted at the HTTP boundary. */
|
||||
userName?: string;
|
||||
schemas?: string[];
|
||||
}
|
||||
export interface ScimStore {
|
||||
list(): Promise<ScimUser[]>;
|
||||
get(id: string): Promise<ScimUser | null>;
|
||||
create(user: Omit<ScimUser, "id">): Promise<ScimUser>;
|
||||
update(id: string, user: Partial<ScimUser>): Promise<ScimUser | null>;
|
||||
delete(id: string): Promise<boolean>;
|
||||
}
|
||||
export function memoryScimStore(): ScimStore {
|
||||
const users = new Map<string, ScimUser>();
|
||||
return {
|
||||
async list() {
|
||||
return [...users.values()];
|
||||
},
|
||||
async get(id) {
|
||||
return users.get(id) ?? null;
|
||||
},
|
||||
async create(user) {
|
||||
const value = { ...user, id: crypto.randomUUID() };
|
||||
users.set(value.id, value);
|
||||
return value;
|
||||
},
|
||||
async update(id, patch) {
|
||||
const current = users.get(id);
|
||||
if (!current) return null;
|
||||
const value = { ...current, ...patch, id };
|
||||
users.set(id, value);
|
||||
return value;
|
||||
},
|
||||
async delete(id) {
|
||||
return users.delete(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
function constantTimeText(left: string, right: string): boolean {
|
||||
const a = new TextEncoder().encode(left);
|
||||
const b = new TextEncoder().encode(right);
|
||||
let mismatch = a.length ^ b.length;
|
||||
for (let index = 0; index < Math.max(a.length, b.length); index++)
|
||||
mismatch |= (a[index] ?? 0) ^ (b[index] ?? 0);
|
||||
return mismatch === 0;
|
||||
}
|
||||
const SCIM_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
|
||||
export function createScimHandler(options: {
|
||||
store: ScimStore;
|
||||
bearerToken: string;
|
||||
basePath?: string;
|
||||
maxBodyBytes?: number;
|
||||
}) {
|
||||
if (options.bearerToken.length < 24)
|
||||
throw new Error("WRN-SCIM-TOKEN: bearer token must contain at least 24 characters.");
|
||||
const base = options.basePath ?? "/scim/v2";
|
||||
return async (request: Request): Promise<Response> => {
|
||||
if (
|
||||
!constantTimeText(request.headers.get("authorization") ?? "", `Bearer ${options.bearerToken}`)
|
||||
)
|
||||
return Response.json({ detail: "Unauthorized" }, { status: 401 });
|
||||
const url = new URL(request.url);
|
||||
const match = new RegExp(
|
||||
`^${base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/Users(?:/([^/]+))?$`,
|
||||
).exec(url.pathname);
|
||||
if (!match) return Response.json({ detail: "Not found" }, { status: 404 });
|
||||
const id = match[1];
|
||||
if (request.method === "GET" && !id) {
|
||||
const values = await options.store.list();
|
||||
return Response.json({
|
||||
schemas: [SCIM_SCHEMA],
|
||||
totalResults: values.length,
|
||||
startIndex: 1,
|
||||
itemsPerPage: values.length,
|
||||
Resources: values,
|
||||
});
|
||||
}
|
||||
if (request.method === "GET" && id) {
|
||||
const value = await options.store.get(id);
|
||||
return value ? Response.json(value) : Response.json({ detail: "Not found" }, { status: 404 });
|
||||
}
|
||||
if (["POST", "PUT", "PATCH"].includes(request.method)) {
|
||||
const text = await request.text();
|
||||
if (new TextEncoder().encode(text).byteLength > (options.maxBodyBytes ?? 64 * 1024))
|
||||
return Response.json({ detail: "Too large" }, { status: 413 });
|
||||
let body: ScimUser;
|
||||
try {
|
||||
body = JSON.parse(text) as ScimUser;
|
||||
} catch {
|
||||
return Response.json({ detail: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
if (!body || typeof body.userName !== "string")
|
||||
return Response.json({ detail: "userName is required" }, { status: 400 });
|
||||
const normalized = {
|
||||
externalId: String(body.externalId ?? body.userName),
|
||||
username: body.userName,
|
||||
displayName: body.displayName,
|
||||
email: body.email,
|
||||
groups: Array.isArray(body.groups) ? body.groups : [],
|
||||
active: body.active !== false,
|
||||
attributes: body.attributes,
|
||||
};
|
||||
const value =
|
||||
request.method === "POST"
|
||||
? await options.store.create(normalized)
|
||||
: id
|
||||
? await options.store.update(id, normalized)
|
||||
: null;
|
||||
return value
|
||||
? Response.json(value, { status: request.method === "POST" ? 201 : 200 })
|
||||
: Response.json({ detail: "Not found" }, { status: 404 });
|
||||
}
|
||||
if (request.method === "DELETE" && id)
|
||||
return new Response(null, { status: (await options.store.delete(id)) ? 204 : 404 });
|
||||
return new Response("Method Not Allowed", { status: 405 });
|
||||
};
|
||||
}
|
||||
|
||||
export interface MachineCredential {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
kind: "api-key" | "service-account";
|
||||
name: string;
|
||||
scopes: string[];
|
||||
secretHash: string;
|
||||
createdAt: number;
|
||||
expiresAt?: number;
|
||||
revokedAt?: number;
|
||||
}
|
||||
const hex = (bytes: Uint8Array) =>
|
||||
Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
async function hash(value: string): Promise<string> {
|
||||
return hex(
|
||||
new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))),
|
||||
);
|
||||
}
|
||||
export function createMachineIdentityManager(now: () => number = Date.now) {
|
||||
const records = new Map<string, MachineCredential>();
|
||||
return {
|
||||
async issue(input: {
|
||||
ownerId: string;
|
||||
name: string;
|
||||
scopes: string[];
|
||||
kind?: MachineCredential["kind"];
|
||||
expiresAt?: number;
|
||||
}) {
|
||||
const id = crypto.randomUUID();
|
||||
const secret = `wrn_${id.replaceAll("-", "")}_${hex(crypto.getRandomValues(new Uint8Array(24)))}`;
|
||||
const record: MachineCredential = {
|
||||
id,
|
||||
ownerId: input.ownerId,
|
||||
kind: input.kind ?? "api-key",
|
||||
name: input.name,
|
||||
scopes: [...new Set(input.scopes)].sort(),
|
||||
secretHash: await hash(secret),
|
||||
createdAt: now(),
|
||||
expiresAt: input.expiresAt,
|
||||
};
|
||||
records.set(id, record);
|
||||
return { secret, credential: { ...record, secretHash: "[REDACTED]" } };
|
||||
},
|
||||
async authenticate(secret: string, requiredScope?: string) {
|
||||
const digest = await hash(secret);
|
||||
for (const record of records.values())
|
||||
if (
|
||||
constantTimeText(record.secretHash, digest) &&
|
||||
!record.revokedAt &&
|
||||
(!record.expiresAt || record.expiresAt > now()) &&
|
||||
(!requiredScope || record.scopes.includes(requiredScope) || record.scopes.includes("*"))
|
||||
)
|
||||
return { ...record, secretHash: "[REDACTED]" };
|
||||
return null;
|
||||
},
|
||||
revoke(id: string) {
|
||||
const record = records.get(id);
|
||||
if (!record) return false;
|
||||
record.revokedAt = now();
|
||||
return true;
|
||||
},
|
||||
list(ownerId: string) {
|
||||
return [...records.values()]
|
||||
.filter((record) => record.ownerId === ownerId)
|
||||
.map((record) => ({ ...record, secretHash: "[REDACTED]" }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface GovernanceEvent {
|
||||
id: string;
|
||||
type: string;
|
||||
subjectId: string;
|
||||
actorId?: string;
|
||||
createdAt: number;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
export function createGovernance(
|
||||
options: {
|
||||
now?: () => number;
|
||||
audit?: (event: GovernanceEvent) => void | Promise<void>;
|
||||
exportSubject?: (subjectId: string) => unknown | Promise<unknown>;
|
||||
deleteSubject?: (subjectId: string) => void | Promise<void>;
|
||||
} = {},
|
||||
) {
|
||||
const now = options.now ?? Date.now;
|
||||
const consents = new Map<
|
||||
string,
|
||||
Map<string, { granted: boolean; version: string; at: number }>
|
||||
>();
|
||||
const approvals = new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
subjectId: string;
|
||||
action: "export" | "delete";
|
||||
status: "pending" | "approved" | "rejected";
|
||||
requestedAt: number;
|
||||
decidedAt?: number;
|
||||
decidedBy?: string;
|
||||
}
|
||||
>();
|
||||
const emit = async (
|
||||
type: string,
|
||||
subjectId: string,
|
||||
actorId?: string,
|
||||
data?: Record<string, unknown>,
|
||||
) =>
|
||||
options.audit?.({ id: crypto.randomUUID(), type, subjectId, actorId, createdAt: now(), data });
|
||||
return {
|
||||
async consent(subjectId: string, purpose: string, granted: boolean, version: string) {
|
||||
const values = consents.get(subjectId) ?? new Map();
|
||||
const value = { granted, version, at: now() };
|
||||
values.set(purpose, value);
|
||||
consents.set(subjectId, values);
|
||||
await emit("consent.changed", subjectId, subjectId, { purpose, granted, version });
|
||||
return value;
|
||||
},
|
||||
consents(subjectId: string) {
|
||||
return Object.fromEntries(consents.get(subjectId) ?? []);
|
||||
},
|
||||
async request(subjectId: string, action: "export" | "delete") {
|
||||
const value = {
|
||||
id: crypto.randomUUID(),
|
||||
subjectId,
|
||||
action,
|
||||
status: "pending" as const,
|
||||
requestedAt: now(),
|
||||
};
|
||||
approvals.set(value.id, value);
|
||||
await emit(`privacy.${action}.requested`, subjectId);
|
||||
return value;
|
||||
},
|
||||
async decide(id: string, actorId: string, approved: boolean) {
|
||||
const request = approvals.get(id);
|
||||
if (!request || request.status !== "pending")
|
||||
throw new Error("WRN-GOVERNANCE-APPROVAL: request is missing or already decided.");
|
||||
const decision = {
|
||||
...request,
|
||||
status: approved ? ("approved" as const) : ("rejected" as const),
|
||||
decidedAt: now(),
|
||||
decidedBy: actorId,
|
||||
};
|
||||
approvals.set(id, decision);
|
||||
let result: unknown;
|
||||
if (approved && request.action === "export")
|
||||
result = await options.exportSubject?.(request.subjectId);
|
||||
if (approved && request.action === "delete") await options.deleteSubject?.(request.subjectId);
|
||||
await emit(`privacy.${request.action}.${decision.status}`, request.subjectId, actorId);
|
||||
return { decision, result };
|
||||
},
|
||||
async enforceRetention(
|
||||
records: Array<{ subjectId: string; createdAt: number }>,
|
||||
maxAgeMs: number,
|
||||
remove: (record: { subjectId: string; createdAt: number }) => void | Promise<void>,
|
||||
) {
|
||||
if (maxAgeMs < 0) throw new RangeError("retention duration must not be negative");
|
||||
const expired = records.filter((record) => record.createdAt + maxAgeMs <= now());
|
||||
for (const record of expired) {
|
||||
await remove(record);
|
||||
await emit("retention.deleted", record.subjectId, undefined, {
|
||||
createdAt: record.createdAt,
|
||||
});
|
||||
}
|
||||
return expired.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
createGovernance,
|
||||
createMachineIdentityManager,
|
||||
createSamlFederation,
|
||||
createScimHandler,
|
||||
discoverOidc,
|
||||
memoryScimStore,
|
||||
oidcAuthorizationUrl,
|
||||
syncDirectory,
|
||||
} from "../src/index.ts";
|
||||
|
||||
describe("enterprise identity", () => {
|
||||
test("discovers OIDC safely and creates PKCE authorization URLs", async () => {
|
||||
const metadata = {
|
||||
issuer: "https://id.test",
|
||||
authorization_endpoint: "https://id.test/auth",
|
||||
token_endpoint: "https://id.test/token",
|
||||
jwks_uri: "https://id.test/jwks",
|
||||
};
|
||||
const discovered = await discoverOidc(metadata.issuer, {
|
||||
fetch: (async () => Response.json(metadata)) as unknown as typeof fetch,
|
||||
});
|
||||
const url = new URL(
|
||||
oidcAuthorizationUrl(discovered, {
|
||||
clientId: "app",
|
||||
redirectUri: "https://app.test/callback",
|
||||
state: "state",
|
||||
nonce: "nonce",
|
||||
codeChallenge: "challenge",
|
||||
}),
|
||||
);
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
});
|
||||
|
||||
test("validates signed SAML boundaries and prevents replay", async () => {
|
||||
const assertion = {
|
||||
id: "assertion-1",
|
||||
issuer: "https://id.test",
|
||||
audience: "app",
|
||||
recipient: "https://app.test/saml",
|
||||
expiresAt: 200,
|
||||
identity: { externalId: "u1", username: "u1", groups: [], active: true },
|
||||
};
|
||||
const federation = createSamlFederation({
|
||||
adapter: { createLoginRequest: () => "request", verifySignedResponse: async () => assertion },
|
||||
issuer: assertion.issuer,
|
||||
audience: assertion.audience,
|
||||
recipient: assertion.recipient,
|
||||
now: () => 100,
|
||||
});
|
||||
expect((await federation.callback("signed")).externalId).toBe("u1");
|
||||
await expect(federation.callback("signed")).rejects.toThrow("WRN-SAML-REPLAY");
|
||||
});
|
||||
|
||||
test("syncs LDAP/AD adapters and provisions SCIM users", async () => {
|
||||
const values: string[] = [];
|
||||
expect(
|
||||
(
|
||||
await syncDirectory(
|
||||
{
|
||||
kind: "active-directory",
|
||||
search: async () => [
|
||||
{ externalId: "u1", username: "user", groups: ["staff"], active: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
baseDn: "dc=test",
|
||||
upsert: (identity) => {
|
||||
values.push(identity.externalId);
|
||||
},
|
||||
},
|
||||
)
|
||||
).synchronized,
|
||||
).toBe(1);
|
||||
const handler = createScimHandler({ store: memoryScimStore(), bearerToken: "a".repeat(32) });
|
||||
const created = await handler(
|
||||
new Request("https://app.test/scim/v2/Users", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${"a".repeat(32)}` },
|
||||
body: JSON.stringify({ userName: "person@example.test", active: true }),
|
||||
}),
|
||||
);
|
||||
expect(created.status).toBe(201);
|
||||
expect(
|
||||
(
|
||||
await handler(
|
||||
new Request("https://app.test/scim/v2/Users", {
|
||||
headers: { authorization: `Bearer ${"a".repeat(32)}` },
|
||||
}),
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(values).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
test("issues scoped machine identities without exposing hashes", async () => {
|
||||
const manager = createMachineIdentityManager(() => 100);
|
||||
const issued = await manager.issue({
|
||||
ownerId: "org",
|
||||
name: "deploy",
|
||||
kind: "service-account",
|
||||
scopes: ["deploy:write"],
|
||||
});
|
||||
expect(issued.credential.secretHash).toBe("[REDACTED]");
|
||||
expect((await manager.authenticate(issued.secret, "deploy:write"))?.kind).toBe(
|
||||
"service-account",
|
||||
);
|
||||
expect(await manager.authenticate(issued.secret, "admin")).toBeNull();
|
||||
});
|
||||
|
||||
test("tracks consent, approvals, export/deletion, retention and audit", async () => {
|
||||
const audit: string[] = [];
|
||||
const deleted: string[] = [];
|
||||
const governance = createGovernance({
|
||||
now: () => 100,
|
||||
audit: (event) => {
|
||||
audit.push(event.type);
|
||||
},
|
||||
exportSubject: (id) => ({ id }),
|
||||
deleteSubject: (id) => {
|
||||
deleted.push(id);
|
||||
},
|
||||
});
|
||||
await governance.consent("u1", "analytics", true, "v2");
|
||||
const exportRequest = await governance.request("u1", "export");
|
||||
expect((await governance.decide(exportRequest.id, "admin", true)).result).toEqual({ id: "u1" });
|
||||
const deleteRequest = await governance.request("u1", "delete");
|
||||
await governance.decide(deleteRequest.id, "admin", true);
|
||||
expect(
|
||||
await governance.enforceRetention([{ subjectId: "u2", createdAt: 0 }], 50, (record) => {
|
||||
deleted.push(record.subjectId);
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(deleted).toEqual(["u1", "u2"]);
|
||||
expect(audit).toContain("consent.changed");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user