From 294f5c31d2b16e4b4193e02b6e6933e4c91f9355 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 22:44:47 +0800 Subject: [PATCH 1/5] feat: add platform management transfer --- platforms/registry/api/src/config/database.ts | 3 +- .../api/src/entities/PlatformManagement.ts | 22 +++++ platforms/registry/api/src/index.ts | 57 ++++++++++++ platforms/registry/api/src/jwt.ts | 38 +++++++- .../1788090000000-platform-management.ts | 21 +++++ .../PlatformManagementService.spec.ts | 53 +++++++++++ .../src/services/PlatformManagementService.ts | 92 +++++++++++++++++++ 7 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 platforms/registry/api/src/entities/PlatformManagement.ts create mode 100644 platforms/registry/api/src/migrations/1788090000000-platform-management.ts create mode 100644 platforms/registry/api/src/services/PlatformManagementService.spec.ts create mode 100644 platforms/registry/api/src/services/PlatformManagementService.ts diff --git a/platforms/registry/api/src/config/database.ts b/platforms/registry/api/src/config/database.ts index 250f8431e..b260c909f 100644 --- a/platforms/registry/api/src/config/database.ts +++ b/platforms/registry/api/src/config/database.ts @@ -1,6 +1,7 @@ import { DataSource } from "typeorm" import { Vault } from "../entities/Vault" import { SoftwareVersion } from "../entities/SoftwareVersion" +import { PlatformManagement } from "../entities/PlatformManagement" // Import Verification entity from evault-core if available (shared database) import * as dotenv from "dotenv" import { join } from "path" @@ -13,7 +14,7 @@ export const AppDataSource = new DataSource({ url: process.env.REGISTRY_DATABASE_URL || "postgresql://postgres:postgres@localhost:5432/registry", synchronize: false, logging: process.env.DB_LOGGING === "true", - entities: [Vault, SoftwareVersion], + entities: [Vault, SoftwareVersion, PlatformManagement], // Verification entity will be handled by evault-core provisioning service migrations: [join(__dirname, "../migrations/*.{ts,js}")], migrationsTableName: "migrations", diff --git a/platforms/registry/api/src/entities/PlatformManagement.ts b/platforms/registry/api/src/entities/PlatformManagement.ts new file mode 100644 index 000000000..94bd1aa66 --- /dev/null +++ b/platforms/registry/api/src/entities/PlatformManagement.ts @@ -0,0 +1,22 @@ +import { Column, CreateDateColumn, Entity, PrimaryColumn, UpdateDateColumn } from "typeorm"; + +@Entity() +export class PlatformManagement { + @PrimaryColumn() + ename!: string; + + @Column() + manager!: string; + + @Column() + profileEnvelopeId!: string; + + @Column({ type: "varchar", length: 64 }) + revokedTokenFingerprint!: string; + + @CreateDateColumn({ type: "timestamptz" }) + createdAt!: Date; + + @UpdateDateColumn({ type: "timestamptz" }) + updatedAt!: Date; +} diff --git a/platforms/registry/api/src/index.ts b/platforms/registry/api/src/index.ts index e367e6fa3..151a166ba 100644 --- a/platforms/registry/api/src/index.ts +++ b/platforms/registry/api/src/index.ts @@ -7,6 +7,7 @@ import { generateEntropy, generatePlatformToken, generateKeyBindingCertificate, import { UriResolutionService } from "./services/UriResolutionService"; import { VaultService } from "./services/VaultService"; import { SoftwareVersionService, SoftwareVersionConflictError, softwareVersionEName } from "./services/SoftwareVersionService"; +import { PlatformManagementService, PlatformManagementConflictError } from "./services/PlatformManagementService"; import fs from "node:fs"; @@ -56,6 +57,7 @@ const initializeDatabase = async () => { // Initialize VaultService const vaultService = new VaultService(AppDataSource.getRepository("Vault")); const softwareVersionService = new SoftwareVersionService(AppDataSource.getRepository("SoftwareVersion")); +const platformManagementService = new PlatformManagementService(AppDataSource.getRepository("PlatformManagement")); // Initialize UriResolutionService (simplified for multi-tenant architecture) const uriResolutionService = new UriResolutionService(); @@ -188,6 +190,61 @@ server.post("/platforms/certification", async (request, reply) => { } }); +server.post( + "/platforms/migrations/inspect-token", + { preHandler: checkSharedSecret }, + async (request, reply) => { + try { + const { token } = request.body as { token?: string }; + if (!token) return reply.status(400).send({ error: "token is required" }); + return await platformManagementService.inspectLegacyToken(token); + } catch (error) { + return reply.status(401).send({ error: error instanceof Error ? error.message : "Invalid platform token" }); + } + }, +); + +server.post( + "/platforms/migrations/activate", + { preHandler: checkSharedSecret }, + async (request, reply) => { + try { + const input = request.body as { ename?: string; manager?: string; profileEnvelopeId?: string; legacyToken?: string }; + if (!input.ename || !input.manager || !input.profileEnvelopeId || !input.legacyToken) { + return reply.status(400).send({ error: "ename, manager, profileEnvelopeId, and legacyToken are required" }); + } + return await platformManagementService.transfer(input as Required); + } catch (error) { + if (error instanceof PlatformManagementConflictError) return reply.status(409).send({ error: error.message }); + return reply.status(401).send({ error: error instanceof Error ? error.message : "Migration activation failed" }); + } + }, +); + +server.post( + "/platforms/management/token", + { preHandler: checkSharedSecret }, + async (request, reply) => { + try { + const { ename, manager } = request.body as { ename?: string; manager?: string }; + if (!ename || !manager) return reply.status(400).send({ error: "ename and manager are required" }); + return { token: await platformManagementService.managerToken(ename, manager) }; + } catch (error) { + return reply.status(403).send({ error: error instanceof Error ? error.message : "Manager token denied" }); + } + }, +); + +server.post( + "/platforms/management/authorize-profile-write", + { preHandler: checkSharedSecret }, + async (request, reply) => { + const input = request.body as { ename?: string; ontology?: string; envelopeId?: string; token?: string }; + if (!input.ename || !input.ontology) return reply.status(400).send({ error: "ename and ontology are required" }); + return platformManagementService.authorizeProfileWrite(input as Required> & typeof input); + }, +); + // Generate key binding certificate (JWT binding ename and publicKey) server.post( "/key-binding-certificate", diff --git a/platforms/registry/api/src/jwt.ts b/platforms/registry/api/src/jwt.ts index bcf3c7caa..5a1db8fa2 100644 --- a/platforms/registry/api/src/jwt.ts +++ b/platforms/registry/api/src/jwt.ts @@ -66,20 +66,50 @@ export async function generatePlatformToken(platform: string): Promise { return token; } -export async function verifyPlatformToken(token: string): Promise { +export async function generateManagedPlatformToken(ename: string, manager: string): Promise { + await initializeKeys(); + return new SignJWT({ + platform: manager, + kind: "platform-manager", + managedEname: ename, + manager, + }) + .setProtectedHeader({ alg: "ES256", kid: "entropy-key-1" }) + .setJti(globalThis.crypto.randomUUID()) + .setIssuedAt() + .setExpirationTime("1h") + .sign(privateKey); +} + +export type PlatformTokenClaims = { + platform: string; + kind?: string; + managedEname?: string; + manager?: string; +}; + +export async function verifyPlatformTokenClaims(token: string): Promise { await initializeKeys(); try { const { payload } = await import("jose").then(({ jwtVerify }) => jwtVerify(token, publicKey, { algorithms: ["ES256"] }) ); - return typeof payload.platform === "string" && payload.platform.trim() - ? payload.platform - : null; + if (typeof payload.platform !== "string" || !payload.platform.trim()) return null; + return { + platform: payload.platform, + ...(typeof payload.kind === "string" && { kind: payload.kind }), + ...(typeof payload.managedEname === "string" && { managedEname: payload.managedEname }), + ...(typeof payload.manager === "string" && { manager: payload.manager }), + }; } catch { return null; } } +export async function verifyPlatformToken(token: string): Promise { + return (await verifyPlatformTokenClaims(token))?.platform ?? null; +} + // Generate and sign a JWT binding ename and publicKey together export async function generateKeyBindingCertificate( ename: string, diff --git a/platforms/registry/api/src/migrations/1788090000000-platform-management.ts b/platforms/registry/api/src/migrations/1788090000000-platform-management.ts new file mode 100644 index 000000000..70eee7888 --- /dev/null +++ b/platforms/registry/api/src/migrations/1788090000000-platform-management.ts @@ -0,0 +1,21 @@ +import type { MigrationInterface, QueryRunner } from "typeorm"; + +export class PlatformManagement1788090000000 implements MigrationInterface { + name = "PlatformManagement1788090000000"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "platform_management" ( + "ename" character varying NOT NULL, + "manager" character varying NOT NULL, + "profileEnvelopeId" character varying NOT NULL, + "revokedTokenFingerprint" character varying(64) NOT NULL, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_platform_management_ename" PRIMARY KEY ("ename") + )`); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "platform_management"`); + } +} diff --git a/platforms/registry/api/src/services/PlatformManagementService.spec.ts b/platforms/registry/api/src/services/PlatformManagementService.spec.ts new file mode 100644 index 000000000..a7f019eef --- /dev/null +++ b/platforms/registry/api/src/services/PlatformManagementService.spec.ts @@ -0,0 +1,53 @@ +import type { Repository } from "typeorm"; +import type { PlatformManagement } from "../entities/PlatformManagement"; +import { generateManagedPlatformToken, verifyPlatformTokenClaims } from "../jwt"; +import { PlatformManagementConflictError, PlatformManagementService, tokenFingerprint } from "./PlatformManagementService"; + +jest.mock("../jwt", () => ({ + generateManagedPlatformToken: jest.fn(async () => "manager-token"), + verifyPlatformTokenClaims: jest.fn(), +})); + +describe("PlatformManagementService", () => { + const records = new Map(); + const repository = { + findOneBy: jest.fn(async ({ ename }: { ename: string }) => records.get(ename) ?? null), + create: jest.fn((input: PlatformManagement) => input), + save: jest.fn(async (input: PlatformManagement) => { + records.set(input.ename, input); + return input; + }), + } as unknown as Repository; + const service = new PlatformManagementService(repository); + + beforeEach(() => { + records.clear(); + jest.clearAllMocks(); + jest.mocked(verifyPlatformTokenClaims).mockResolvedValue({ platform: "legacy-publisher" }); + }); + + it("activates one idempotent management transfer and revokes the supplied token", async () => { + const input = { ename: "@platform", manager: "https://gitw3.example", profileEnvelopeId: "profile-1", legacyToken: "old-secret" }; + const first = await service.transfer(input); + const repeated = await service.transfer(input); + + expect(first.management.revokedTokenFingerprint).toBe(tokenFingerprint("old-secret")); + expect(repeated.management).toEqual(first.management); + expect(generateManagedPlatformToken).toHaveBeenCalledTimes(2); + }); + + it("rejects a competing transfer", async () => { + await service.transfer({ ename: "@platform", manager: "manager-a", profileEnvelopeId: "profile-1", legacyToken: "old-secret" }); + await expect(service.transfer({ ename: "@platform", manager: "manager-b", profileEnvelopeId: "profile-1", legacyToken: "old-secret" })) + .rejects.toBeInstanceOf(PlatformManagementConflictError); + }); + + it("allows only the active manager to write the managed profile envelope", async () => { + await service.transfer({ ename: "@platform", manager: "manager-a", profileEnvelopeId: "profile-1", legacyToken: "old-secret" }); + + expect(await service.authorizeProfileWrite({ ename: "@platform", ontology: "other" })).toEqual({ managed: false, allowed: true }); + expect((await service.authorizeProfileWrite({ ename: "@platform", ontology: "550e8400-e29b-41d4-a716-446655440000", envelopeId: "profile-1", token: "old-secret" })).allowed).toBe(false); + jest.mocked(verifyPlatformTokenClaims).mockResolvedValue({ platform: "manager-a", kind: "platform-manager", managedEname: "@platform", manager: "manager-a" }); + expect(await service.authorizeProfileWrite({ ename: "@platform", ontology: "550e8400-e29b-41d4-a716-446655440000", envelopeId: "profile-1", token: "new-secret" })).toEqual({ managed: true, allowed: true }); + }); +}); diff --git a/platforms/registry/api/src/services/PlatformManagementService.ts b/platforms/registry/api/src/services/PlatformManagementService.ts new file mode 100644 index 000000000..5649789cf --- /dev/null +++ b/platforms/registry/api/src/services/PlatformManagementService.ts @@ -0,0 +1,92 @@ +import { createHash } from "node:crypto"; +import type { Repository } from "typeorm"; +import type { PlatformManagement } from "../entities/PlatformManagement"; +import { generateManagedPlatformToken, verifyPlatformTokenClaims } from "../jwt"; + +export const PLATFORM_PROFILE_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; + +export class PlatformManagementConflictError extends Error {} + +export function tokenFingerprint(token: string): string { + return createHash("sha256").update(token, "utf8").digest("hex"); +} + +export class PlatformManagementService { + constructor(private readonly repository: Repository) {} + + async inspectLegacyToken(token: string): Promise<{ platform: string; fingerprint: string }> { + const claims = await verifyPlatformTokenClaims(token); + if (!claims || claims.kind === "platform-manager") { + throw new Error("A valid legacy platform token is required"); + } + return { platform: claims.platform, fingerprint: tokenFingerprint(token) }; + } + + async find(ename: string): Promise { + return this.repository.findOneBy({ ename }); + } + + async transfer(input: { + ename: string; + manager: string; + profileEnvelopeId: string; + legacyToken: string; + }): Promise<{ management: PlatformManagement; token: string }> { + const inspected = await this.inspectLegacyToken(input.legacyToken); + const existing = await this.find(input.ename); + const fingerprint = inspected.fingerprint; + if (existing) { + if ( + existing.manager !== input.manager || + existing.profileEnvelopeId !== input.profileEnvelopeId || + existing.revokedTokenFingerprint !== fingerprint + ) { + throw new PlatformManagementConflictError("This platform is already managed by another migration"); + } + return { management: existing, token: await generateManagedPlatformToken(input.ename, input.manager) }; + } + + const management = await this.repository.save( + this.repository.create({ + ename: input.ename, + manager: input.manager, + profileEnvelopeId: input.profileEnvelopeId, + revokedTokenFingerprint: fingerprint, + }), + ); + return { management, token: await generateManagedPlatformToken(input.ename, input.manager) }; + } + + async managerToken(ename: string, manager: string): Promise { + const management = await this.find(ename); + if (!management || management.manager !== manager) { + throw new Error("The requested manager does not control this platform"); + } + return generateManagedPlatformToken(ename, manager); + } + + async authorizeProfileWrite(input: { + ename: string; + ontology: string; + envelopeId?: string; + token?: string; + }): Promise<{ managed: boolean; allowed: boolean; reason?: string }> { + if (input.ontology !== PLATFORM_PROFILE_ONTOLOGY) { + return { managed: false, allowed: true }; + } + const management = await this.find(input.ename); + if (!management) return { managed: false, allowed: true }; + if (input.envelopeId && input.envelopeId !== management.profileEnvelopeId) { + return { managed: true, allowed: false, reason: "The managed platform profile has a different envelope ID" }; + } + if (!input.token) { + return { managed: true, allowed: false, reason: "A platform manager token is required" }; + } + if (tokenFingerprint(input.token) === management.revokedTokenFingerprint) { + return { managed: true, allowed: false, reason: "The legacy platform token was revoked during migration" }; + } + const claims = await verifyPlatformTokenClaims(input.token); + const allowed = !!claims && claims.kind === "platform-manager" && claims.managedEname === input.ename && claims.manager === management.manager; + return { managed: true, allowed, ...(!allowed && { reason: "The token is not the active platform manager" }) }; + } +} From 0a7761e55a92a8008e89ba4d2b224d91faea6740 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 22:46:42 +0800 Subject: [PATCH 2/5] feat: protect managed platform profiles --- .../core/protocol/vault-access-guard.spec.ts | 51 ++++++++++++++- .../src/core/protocol/vault-access-guard.ts | 63 +++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts b/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts index ca3dd2482..fdc7a5997 100644 --- a/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts +++ b/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts @@ -53,6 +53,56 @@ describe("VaultAccessGuard", () => { keys: [{ ...testJWK, d: undefined }], // Public key only }, }); + mockedAxios.post.mockResolvedValue({ data: { managed: false, allowed: true } }); + process.env.REGISTRY_SHARED_SECRET = "registry-secret"; + }); + + describe("managed PlatformProfile writes", () => { + const profileInput = { + ontology: "550e8400-e29b-41d4-a716-446655440000", + payload: { platformName: "example" }, + acl: ["*"], + }; + + it("rejects a revoked legacy token before the resolver runs", async () => { + mockedAxios.post.mockResolvedValue({ + data: { managed: true, allowed: false, reason: "The legacy platform token was revoked during migration" }, + }); + const resolver = vi.fn(async () => ({ id: "profile" })); + const wrapped = guard.middleware(resolver); + const context = createMockContext({ + eName: "@platform", + request: { headers: new Headers({ authorization: "Bearer legacy-token" }) } as any, + }); + + await expect(wrapped(null, { id: "profile-1", input: profileInput }, context)).rejects.toThrow("revoked during migration"); + expect(resolver).not.toHaveBeenCalled(); + }); + + it("allows the active manager token at the original envelope", async () => { + mockedAxios.post.mockResolvedValue({ data: { managed: true, allowed: true } }); + const resolver = vi.fn(async () => ({ id: "profile" })); + const wrapped = guard.middleware(resolver); + const managerToken = await createValidToken({ + platform: "manager-a", + kind: "platform-manager", + managedEname: "@platform", + manager: "manager-a", + }); + const context = createMockContext({ + eName: "@platform", + request: { headers: new Headers({ authorization: `Bearer ${managerToken}` }) } as any, + }); + mockedAxios.get.mockResolvedValue({ data: { keys: [{ ...testJWK, d: undefined }] } }); + + await wrapped(null, { id: "profile-1", input: profileInput }, context); + expect(mockedAxios.post).toHaveBeenCalledWith( + "http://localhost:4322/platforms/management/authorize-profile-write", + expect.objectContaining({ ename: "@platform", envelopeId: "profile-1", token: managerToken }), + expect.anything(), + ); + expect(resolver).toHaveBeenCalledOnce(); + }); }); const createMockContext = (overrides: Partial = {}): VaultContext => { @@ -887,4 +937,3 @@ describe("VaultAccessGuard", () => { }); }); }); - diff --git a/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts b/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts index 203a80a68..084b85ba0 100644 --- a/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts +++ b/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts @@ -18,10 +18,69 @@ type CachedJWKS = { const jwksCache = new Map(); const JWKS_TTL_MS = 24 * 60 * 60 * 1000; const JWKS_FETCH_TIMEOUT_MS = 5000; +const PLATFORM_PROFILE_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; export class VaultAccessGuard { constructor(private db: DbService) {} + private bearerToken(context: VaultContext): string | undefined { + const authHeader = + context.request?.headers?.get("authorization") ?? + context.request?.headers?.get("Authorization"); + return authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined; + } + + /** + * Migrated PlatformProfiles have one Registry-recorded manager. This check + * is intentionally scoped to that ontology so PPA decisions and every + * unrelated eVault document keep their existing authorization behavior. + */ + private async validateManagedProfileWrite( + context: VaultContext, + input: { ontology?: unknown } | undefined, + envelopeId?: string, + ): Promise { + if (input?.ontology !== PLATFORM_PROFILE_ONTOLOGY) return; + if (!context.eName) throw new Error("X-ENAME header is required for a platform profile write"); + const registryUrl = process.env.PUBLIC_REGISTRY_URL || process.env.REGISTRY_URL; + const sharedSecret = process.env.REGISTRY_SHARED_SECRET; + if (!registryUrl || !sharedSecret) { + throw new Error("Managed platform profile authorization is unavailable"); + } + try { + const response = await axios.post( + new URL("/platforms/management/authorize-profile-write", registryUrl).toString(), + { + ename: context.eName, + ontology: input.ontology, + ...(envelopeId && { envelopeId }), + ...(this.bearerToken(context) && { token: this.bearerToken(context) }), + }, + { + timeout: JWKS_FETCH_TIMEOUT_MS, + headers: { Authorization: `Bearer ${sharedSecret}` }, + }, + ); + if (response.data?.managed && !response.data?.allowed) { + throw new Error(response.data?.reason || "The platform profile is managed by another publisher"); + } + } catch (error) { + if (error instanceof Error && ( + error.message === "The platform profile is managed by another publisher" || + error.message === "The legacy platform token was revoked during migration" || + error.message === "The token is not the active platform manager" || + error.message === "A platform manager token is required" || + error.message === "The managed platform profile has a different envelope ID" + )) { + throw error; + } + const reason = axios.isAxiosError(error) && typeof error.response?.data?.error === "string" + ? error.response.data.error + : "Registry management verification failed"; + throw new Error(reason); + } + } + /** * Validates JWT token from Authorization header * @param authHeader - The Authorization header value @@ -256,6 +315,10 @@ export class VaultAccessGuard { "acl" in args.input && !args.id; // storeMetaEnvelope doesn't have id, updateMetaEnvelopeById does + await timed("guard.validateManagedProfileWrite", () => + this.validateManagedProfileWrite(context, args.input, args.id), + ); + // CRITICAL: Validate authentication BEFORE executing any resolver await timed("guard.validateAuthentication", () => this.validateAuthentication(context, isStoreOperation), From a237a01aaf83af6c597be9c185323e3b041abfad Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 23:03:14 +0800 Subject: [PATCH 3/5] docs: document profile management --- infrastructure/evault-core/README.md | 5 +++++ platforms/registry/api/REGISTRY_PROTOCOL.md | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/infrastructure/evault-core/README.md b/infrastructure/evault-core/README.md index 6546befbb..18024ed04 100644 --- a/infrastructure/evault-core/README.md +++ b/infrastructure/evault-core/README.md @@ -59,6 +59,11 @@ sudo nomad agent -dev -network-interface=eth0 -log-level=DEBUG -bind=0.0.0.0 ## Project Setup +Managed PlatformProfile enforcement requires `PUBLIC_REGISTRY_URL` (or `REGISTRY_URL`) and the same +`REGISTRY_SHARED_SECRET` configured on Registry. eVault asks Registry to authorize writes only for the +PlatformProfile ontology. Once an eName is managed, Registry outages fail those profile writes closed; +other ontologies keep their existing behavior. + 1. Install dependencies: ```bash diff --git a/platforms/registry/api/REGISTRY_PROTOCOL.md b/platforms/registry/api/REGISTRY_PROTOCOL.md index 0ec2c3699..dd0e5e7f1 100644 --- a/platforms/registry/api/REGISTRY_PROTOCOL.md +++ b/platforms/registry/api/REGISTRY_PROTOCOL.md @@ -219,6 +219,27 @@ Authorization: Bearer } ``` +### 5.1 Managed PlatformProfile Migration + +The management API is service-to-service only and requires +`Authorization: Bearer `. It does not expose the submitted legacy token in +responses or logs. + +- `POST /platforms/migrations/inspect-token` verifies a legacy platform JWT and returns its SHA-256 + fingerprint. +- `POST /platforms/migrations/activate` atomically binds an eName and its original PlatformProfile + envelope ID to one manager, records the supplied legacy-token fingerprint as revoked, and returns a + short-lived manager-scoped token. Repeating the identical transfer is idempotent; a competing + transfer returns `409`. +- `POST /platforms/management/token` issues a new short-lived token only to the recorded manager. +- `POST /platforms/management/authorize-profile-write` is called by eVault before a PlatformProfile + write. Unmanaged eNames retain legacy behavior. Managed profiles accept only their active manager + token and original envelope ID. + +The write restriction is scoped to User-profile ontology +`550e8400-e29b-41d4-a716-446655440000`. PPA accreditation envelopes and unrelated eVault records are +not management writes and retain their existing authorization paths. + ### 6. Platform Discovery Protocol **Method**: `GET /platforms` From 77d0a2b570d0db0f1065f4ce7bdef4c7f469bf16 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 23:33:25 +0800 Subject: [PATCH 4/5] fix: drop the deployment assurance dimension A release under review has not been deployed yet, so the row was always L0 and dragged every assessment down for a fact about the future. Framework version 3: the scoring set changed, so an assessment judged under version 2 is not comparable to one judged now. Claude-Session: https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ --- .../ppa/config/certification-framework.json | 35 +------------ services/ppa/src/lib/levels.spec.ts | 2 +- services/ppa/src/lib/levels.ts | 2 +- services/ppa/src/lib/server/aaas.ts | 50 ------------------- services/ppa/src/lib/server/framework.ts | 13 +---- .../submissions/[ename]/+page.server.ts | 14 +----- 6 files changed, 7 insertions(+), 109 deletions(-) diff --git a/services/ppa/config/certification-framework.json b/services/ppa/config/certification-framework.json index c4bc4f521..c6b02a6b8 100644 --- a/services/ppa/config/certification-framework.json +++ b/services/ppa/config/certification-framework.json @@ -1,6 +1,6 @@ { - "$comment": "The PPA application certification matrix, transcribed from 'Post-Platforms Certification Framework — Application Certification Framework Concept v2'. The document calls its quantitative thresholds provisional policy parameters, so this file is versioned and every assessment records the version that judged it. Each dimension lists its distinct requirement texts; `level` is the highest certification level that requirement satisfies, so a repeated requirement collapses to one option. `source: derived` means the app answers the row itself, from the release proof, the actors’ binding documents, attested deployments or signed eReputation references.", - "frameworkVersion": "2", + "$comment": "The PPA application certification matrix, transcribed from 'Post-Platforms Certification Framework — Application Certification Framework Concept v2'. The document calls its quantitative thresholds provisional policy parameters, so this file is versioned and every assessment records the version that judged it. Each dimension lists its distinct requirement texts; `level` is the highest certification level that requirement satisfies, so a repeated requirement collapses to one option. `source: derived` means the app answers the row itself, from the release proof, the actors’ binding documents or signed eReputation references.", + "frameworkVersion": "3", "levels": [ { "id": "L0", @@ -351,37 +351,6 @@ } ] }, - { - "id": "deployment-assurance", - "label": "Deployment assurance", - "source": "derived", - "options": [ - { - "level": 0, - "label": "Nothing ties what is running to this release" - }, - { - "level": 1, - "label": "The team states the deployment matches the release" - }, - { - "level": 2, - "label": "Version records line up with the release" - }, - { - "level": 3, - "label": "A signed deployment attests to this exact release" - }, - { - "level": 4, - "label": "Signed artefacts or hashes match, with deployment logs" - }, - { - "level": 5, - "label": "A reproducible build matches the deployed artefact" - } - ] - }, { "id": "key-assurance", "label": "Key / infrastructure assurance", diff --git a/services/ppa/src/lib/levels.spec.ts b/services/ppa/src/lib/levels.spec.ts index ec6bb4429..015c3c011 100644 --- a/services/ppa/src/lib/levels.spec.ts +++ b/services/ppa/src/lib/levels.spec.ts @@ -79,7 +79,7 @@ describe("computeLevel", () => { it("still weighs several weak rows heavily", () => { // Two rows at L0 and three at L1, against a spread up to L5. - const weak = ["functional-review", "deployment-assurance"]; + const weak = ["functional-review", "code-review"]; const weaker = ["provenance", "actor-reputation", "key-assurance"]; const answers = [ ...allAt(5).filter( diff --git a/services/ppa/src/lib/levels.ts b/services/ppa/src/lib/levels.ts index 8ab64c1cd..6c0294800 100644 --- a/services/ppa/src/lib/levels.ts +++ b/services/ppa/src/lib/levels.ts @@ -102,7 +102,7 @@ export interface ComputedLevel { * an otherwise strong release to its own value the way a strict minimum did. * * The mean is taken over level + 1 and shifted back afterwards. L0 is a real, - * expected answer on this scale (deployment assurance of "None" is L0), and a + * expected answer on this scale (a code review nobody performed is L0), and a * plain geometric mean multiplies by zero, so a single such row would collapse * the score to zero no matter how strong the other fifteen were. * diff --git a/services/ppa/src/lib/server/aaas.ts b/services/ppa/src/lib/server/aaas.ts index 3cff54133..5a8cbb274 100644 --- a/services/ppa/src/lib/server/aaas.ts +++ b/services/ppa/src/lib/server/aaas.ts @@ -611,53 +611,3 @@ export async function currentAccreditations(): Promise { - if (!awarenessApiKey()) return []; - - let packets: Packet[]; - try { - packets = await all({ ontology: DEPLOYMENT_PROFILE_ONTOLOGY }); - } catch (error) { - console.error("[ppa/aaas] failed loading deployments:", error); - return []; - } - - const byDeployment = new Map(); - for (const packet of packets) { - const data = packet.data; - if (!data) continue; - if (str(data.platformEname) !== platformEName) continue; - if (str(data.version) !== version) continue; - const ename = str(data.deploymentEname); - if (!ename) continue; - byDeployment.set(ename, { - deploymentEName: ename, - platformEName: str(data.platformEname), - version: str(data.version), - releaseTag: str(data.releaseTag), - commitSha: str(data.commitSha), - environment: str(data.environment), - }); - } - return Array.from(byDeployment.values()); -} diff --git a/services/ppa/src/lib/server/framework.ts b/services/ppa/src/lib/server/framework.ts index 355be6690..1337b21b0 100644 --- a/services/ppa/src/lib/server/framework.ts +++ b/services/ppa/src/lib/server/framework.ts @@ -10,7 +10,6 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import type { ActorIdentity } from "./identity"; import type { Submission } from "./ontology"; -import type { DeploymentRecord } from "./aaas"; import type { ReputationEvidence } from "./reputation"; import type { Framework, IdentityLevel } from "$lib/levels"; @@ -56,11 +55,10 @@ export function deriveAnswers( submission: Submission; minimumIal: IdentityLevel; actors: ActorIdentity[]; - deployments: DeploymentRecord[]; reputation: ReputationEvidence; }, ): DerivedAnswer[] { - const { submission, minimumIal, actors, deployments, reputation } = context; + const { submission, minimumIal, actors, reputation } = context; const at = (id: string, level: number) => optionAtLevel(framework, id, level); // The submission is only in the queue at all because its release statement @@ -106,15 +104,6 @@ export function deriveAnswers( : "No manifest commit recorded.", }); - answers.push({ - id: "deployment-assurance", - option: at("deployment-assurance", deployments.length > 0 ? 3 : 0), - evidence: - deployments.length > 0 - ? `${deployments.length} deployment${deployments.length === 1 ? "" : "s"} attested against this exact release.` - : "No deployment has been attested against this release.", - }); - // The framework's reputation thresholds are counts, so they are counted. // Signed references are public, which is what makes this evidence rather // than an assertion. diff --git a/services/ppa/src/routes/submissions/[ename]/+page.server.ts b/services/ppa/src/routes/submissions/[ename]/+page.server.ts index 5df1384f4..651116a76 100644 --- a/services/ppa/src/routes/submissions/[ename]/+page.server.ts +++ b/services/ppa/src/routes/submissions/[ename]/+page.server.ts @@ -7,7 +7,6 @@ import { listAccreditations, findMessenger, getAuthors, - listDeployments, listSubmissions, } from "$lib/server/aaas"; import { storeAccreditation, storeAssessment } from "$lib/server/evault"; @@ -68,15 +67,11 @@ export const load: PageServerLoad = async ({ params }) => { ); const minimumIal = minimumIdentity(identities as ActorIdentity[]); - const [deployments, reputation] = await Promise.all([ - listDeployments(ename, submission.version).catch(() => []), - collectReputation(submission.platformName, identities), - ]); + const reputation = await collectReputation(submission.platformName, identities); const derivedAnswers = deriveAnswers(framework, { submission, minimumIal, actors: identities as ActorIdentity[], - deployments, reputation, }); @@ -104,7 +99,6 @@ export const load: PageServerLoad = async ({ params }) => { actors: identities, minimumIal, derivedAnswers, - deployments, reputation, repositoryUrl, authors: await getAuthors(submission.authorEnames, messenger), @@ -212,15 +206,11 @@ export const actions: Actions = { })), ); const minimumIal = minimumIdentity(identities); - const [deployments, reputation] = await Promise.all([ - listDeployments(ename, submission.version).catch(() => []), - collectReputation(submission.platformName, identities), - ]); + const reputation = await collectReputation(submission.platformName, identities); const derived = deriveAnswers(framework, { submission, minimumIal, actors: identities, - deployments, reputation, }); const allAnswers = [ From db8562ed7984eee3b32671edda3c1481bcfd40f3 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 23:36:23 +0800 Subject: [PATCH 5/5] fix: describe the geometric mean, and reload the matrix when it changes The header still described the minimum rule the geometric mean replaced, and the framework was cached for the life of the process, so editing the policy file appeared to do nothing. Claude-Session: https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ --- services/ppa/src/lib/AssessmentMatrix.svelte | 14 ++++++--- services/ppa/src/lib/server/framework.ts | 31 ++++++++++++++++---- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/services/ppa/src/lib/AssessmentMatrix.svelte b/services/ppa/src/lib/AssessmentMatrix.svelte index 0380e27cc..c605375fe 100644 --- a/services/ppa/src/lib/AssessmentMatrix.svelte +++ b/services/ppa/src/lib/AssessmentMatrix.svelte @@ -78,8 +78,9 @@

Assessment

- The level is the weakest of these dimensions — a strong result in one - does not make up for a weakness in another. Framework v{framework.frameworkVersion}. + Every dimension counts. The level is their geometric mean, so a weak row + pulls the result down far more than an average would, without one row + pinning the rest. Framework v{framework.frameworkVersion}.

@@ -104,13 +105,18 @@

An unanswered dimension counts as no evidence.

+ {:else if result.blocked && limitingLabel} +

+ {limitingLabel} + is unanswered or fails outright, so no level can be awarded. +

{:else if limitingLabel}

- Held at {result.level ?? "no level"} by + Weakest row: {limitingLabel}.

- Raising that one row is what raises the level. + It drags the result hardest, but every row moves it.

{/if} diff --git a/services/ppa/src/lib/server/framework.ts b/services/ppa/src/lib/server/framework.ts index 1337b21b0..1a02d25d7 100644 --- a/services/ppa/src/lib/server/framework.ts +++ b/services/ppa/src/lib/server/framework.ts @@ -6,7 +6,7 @@ * version that judged it. */ -import { readFile } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import path from "node:path"; import type { ActorIdentity } from "./identity"; import type { Submission } from "./ontology"; @@ -14,15 +14,34 @@ import type { ReputationEvidence } from "./reputation"; import type { Framework, IdentityLevel } from "$lib/levels"; const CACHE = Symbol.for("ppa.framework"); -const store = globalThis as typeof globalThis & { [CACHE]?: Framework }; +const store = globalThis as typeof globalThis & { + [CACHE]?: { mtimeMs: number; framework: Framework }; +}; +/** + * Reloads when the file changes rather than caching for the life of the + * process. This is editable policy, and a cache that outlives an edit means a + * reviewer changes the matrix, sees no difference, and has no way to tell + * whether the file or the app is wrong. + */ export async function loadFramework(): Promise { - if (store[CACHE]) return store[CACHE]; // cwd is services/ppa under both `vite dev` and `node build/index.js`. const file = path.resolve(process.cwd(), "config/certification-framework.json"); - const framework = JSON.parse(await readFile(file, "utf8")) as Framework; - store[CACHE] = framework; - return framework; + const cached = store[CACHE]; + try { + const { mtimeMs } = await stat(file); + if (cached && cached.mtimeMs === mtimeMs) return cached.framework; + const framework = JSON.parse(await readFile(file, "utf8")) as Framework; + store[CACHE] = { mtimeMs, framework }; + return framework; + } catch (error) { + // An unreadable file must not blank the matrix mid-review. + if (cached) { + console.error("[ppa/framework] could not reload the matrix:", error); + return cached.framework; + } + throw error; + } } /** Index of the option carrying a given level, for building derived answers. */