feat: gate amplify boosts behind per-connection publish governance
A boost to a gated connection (admin1/admin_majority/admin_consensus) now holds with awaiting_publish_approval on the same publish-phase poll the publish route uses, instead of executing the reblog. The holder re-invokes amplify after the pass (hold-and-retry: no durable amplify queue exists and the poll-close transaction must not do network I/O). Unset/auto connections behave exactly as before; the route answers 202 on hold, 200 on execute. Adds amplify-governance IT suite (3 live-DB tests).
This commit is contained in:
parent
b89dea5c93
commit
d6eb39ff27
3 changed files with 220 additions and 5 deletions
188
apps/api/src/publishing/amplify-governance.integration.test.ts
Normal file
188
apps/api/src/publishing/amplify-governance.integration.test.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Pool, type PoolClient } from "pg";
|
||||||
|
import { PublishingExecutionRepository, assertPublishingSchema, type AttemptRef } from "@tactical-media-broadcast-hub/publishing-execution";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Amplify behind per-connection publish governance (live PostgreSQL):
|
||||||
|
* - gated (admin1): amplify holds with the 202 body, reuses the
|
||||||
|
* approval-time publish poll, and executes nothing (fetch is stubbed to
|
||||||
|
* throw, so any execution attempt explodes the test)
|
||||||
|
* - release: after the poll passes, amplify proceeds past the gate (it then
|
||||||
|
* stops at the vault credential read — there is no real credential here —
|
||||||
|
* which proves the gate released instead of holding)
|
||||||
|
* - manual (unset): amplify proceeds with no poll and no hold, as before
|
||||||
|
*
|
||||||
|
* Owns its workspace in the chatgov IT database (CHATGOV_IT_DATABASE_URL +
|
||||||
|
* CHATGOV_IT_SUPER_DATABASE_URL, migrated through 033). Skips clean.
|
||||||
|
*/
|
||||||
|
const appUrl = process.env.CHATGOV_IT_DATABASE_URL;
|
||||||
|
const ownerUrl = process.env.CHATGOV_IT_SUPER_DATABASE_URL;
|
||||||
|
|
||||||
|
type Modules = {
|
||||||
|
publishing: typeof import("./publishing.service.js");
|
||||||
|
polls: typeof import("../chatgov/poll.service.js");
|
||||||
|
db: typeof import("../db/client.js");
|
||||||
|
};
|
||||||
|
|
||||||
|
describe.skipIf(appUrl === undefined || ownerUrl === undefined)("amplify publish governance (live DB)", () => {
|
||||||
|
let m: Modules;
|
||||||
|
let ownerPool: Pool;
|
||||||
|
let appPool: Pool;
|
||||||
|
const suffix = `${Date.now()}${Math.floor(Math.random() * 1e6)}`;
|
||||||
|
let ws: string;
|
||||||
|
let ownerId: string;
|
||||||
|
let admin2: string;
|
||||||
|
let admin3: string;
|
||||||
|
let editorId: string;
|
||||||
|
let gatedConn: string;
|
||||||
|
let manualConn: string;
|
||||||
|
|
||||||
|
async function inTenant<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||||
|
const client = await ownerPool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
await client.query("SELECT set_config('app.workspace_id', $1, true)", [ws]);
|
||||||
|
const result = await fn(client);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
process.env.DATABASE_URL = appUrl;
|
||||||
|
process.env.SUPER_DATABASE_URL = ownerUrl;
|
||||||
|
m = {
|
||||||
|
publishing: await import("./publishing.service.js"),
|
||||||
|
polls: await import("../chatgov/poll.service.js"),
|
||||||
|
db: await import("../db/client.js"),
|
||||||
|
};
|
||||||
|
ownerPool = new Pool({ connectionString: ownerUrl });
|
||||||
|
appPool = new Pool({ connectionString: appUrl });
|
||||||
|
// Any network attempt means the gate failed to hold: explode loudly.
|
||||||
|
vi.stubGlobal("fetch", () => { throw new Error("amplify must not touch the network while held"); });
|
||||||
|
const c = await ownerPool.connect();
|
||||||
|
try {
|
||||||
|
ws = (await c.query("INSERT INTO workspaces (name, slug) VALUES ($1, $2) RETURNING id", ["ampgate", `ampgate-${suffix}`])).rows[0].id as string;
|
||||||
|
const user = async (name: string): Promise<string> => (await c.query(
|
||||||
|
"INSERT INTO users (external_id, username, email, display_name) VALUES ($1, $2, $3, $4) RETURNING id",
|
||||||
|
[`ampgate:${name}:${suffix}`, `ampgate-${name}-${suffix}`, `ampgate-${name}-${suffix}@example.com`, name],
|
||||||
|
)).rows[0].id as string;
|
||||||
|
ownerId = await user("owner");
|
||||||
|
admin2 = await user("admin2");
|
||||||
|
admin3 = await user("admin3");
|
||||||
|
editorId = await user("editor");
|
||||||
|
for (const [id, role] of [[ownerId, "Owner"], [admin2, "Admin"], [admin3, "Admin"], [editorId, "Editor"]] as const) {
|
||||||
|
await c.query("INSERT INTO memberships (workspace_id, user_id, role, status) VALUES ($1, $2, $3, 'active')", [ws, id, role]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
c.release();
|
||||||
|
}
|
||||||
|
await inTenant(async (client) => {
|
||||||
|
for (const [key, fields] of [["gated", { handle: "gated", publishGovernance: "admin1" }], ["manual", { handle: "manual" }]] as const) {
|
||||||
|
const id = (await client.query("INSERT INTO connections (workspace_id, provider, channel_id, fields) VALUES ($1, 'mastodon', $2, $3) RETURNING id",
|
||||||
|
[ws, `ampgate-${key}`, fields])).rows[0].id as string;
|
||||||
|
if (key === "gated") gatedConn = id; else manualConn = id;
|
||||||
|
await client.query("INSERT INTO credentials (workspace_id, connection_id, kind, ciphertext) VALUES ($1, $2, 'connector-oauth', '\\x00')", [ws, id]);
|
||||||
|
await client.query(`INSERT INTO posting_policy_snapshots (workspace_id, destination_kind, account_id, effective, policy_version, resolved_from)
|
||||||
|
VALUES ($1, 'account', $2, '{"version":1,"mode":"immediate"}', 'pv-immediate', '{}')`, [ws, id]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
if (ownerPool !== undefined) {
|
||||||
|
await ownerPool.query("DELETE FROM workspaces WHERE id = $1", [ws]);
|
||||||
|
await ownerPool.query("DELETE FROM users WHERE external_id LIKE $1", [`ampgate:%:${suffix}`]);
|
||||||
|
await ownerPool.end();
|
||||||
|
}
|
||||||
|
await appPool?.end();
|
||||||
|
await m?.db.pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
const owner = () => ({ userId: ownerId, role: "Owner" });
|
||||||
|
|
||||||
|
function service() {
|
||||||
|
const starts: AttemptRef[] = [];
|
||||||
|
return new m.publishing.PublishingService({
|
||||||
|
tenantTransaction: m.db.withTenant,
|
||||||
|
workflowStarter: { startScheduledPublish: vi.fn(async (ref: AttemptRef) => { starts.push(ref); }) },
|
||||||
|
repository: new PublishingExecutionRepository(m.db.pool),
|
||||||
|
assertReady: () => assertPublishingSchema(m.db.pool),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boostDraft(connectionId: string): Promise<string> {
|
||||||
|
const publishing = service();
|
||||||
|
const draft = await publishing.createDraft(ws, { userId: editorId, role: "Editor" }, {
|
||||||
|
text: `boost ${connectionId} ${Math.random()}`, mediaAssetIds: [], visibility: "public",
|
||||||
|
providerMetadata: { connectionId, sourceRemoteRef: "123456789" },
|
||||||
|
});
|
||||||
|
await publishing.submitForReview(ws, { userId: editorId, role: "Editor" }, draft.id);
|
||||||
|
await publishing.approve(ws, owner(), draft.id);
|
||||||
|
return draft.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vote = (pollId: string, userId: string, ballot: "yes" | "no" | "veto") =>
|
||||||
|
new m.polls.PollService().vote(ws, pollId, { ballot }, { kind: "user", userId });
|
||||||
|
|
||||||
|
async function publishPolls(draftId: string) {
|
||||||
|
return inTenant(async (c) => (await c.query(
|
||||||
|
"SELECT id, state FROM polls WHERE draft_id = $1 AND subject_kind = 'draft_publish' ORDER BY created_at",
|
||||||
|
[draftId])).rows as Array<{ id: string; state: string }>);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function heldAudits(draftId: string) {
|
||||||
|
return inTenant(async (c) => (await c.query(
|
||||||
|
"SELECT diff FROM audit_log WHERE target_id = $1 AND action = 'draft.amplify_held'", [draftId])).rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("gated: amplify holds with the 202 body on the approval-time poll and executes nothing", async () => {
|
||||||
|
const draftId = await boostDraft(gatedConn);
|
||||||
|
const [poll] = await publishPolls(draftId);
|
||||||
|
expect(poll?.state).toBe("open");
|
||||||
|
|
||||||
|
const held = await service().amplifyDraft(ws, owner(), draftId, gatedConn, "repost");
|
||||||
|
expect(held).toMatchObject({
|
||||||
|
state: "awaiting_publish_approval", draftId, connectionId: gatedConn,
|
||||||
|
publishGovernance: "admin1", pollId: poll?.id,
|
||||||
|
});
|
||||||
|
// The approval-time poll is reused, not duplicated.
|
||||||
|
expect(await publishPolls(draftId)).toHaveLength(1);
|
||||||
|
const audits = await heldAudits(draftId);
|
||||||
|
expect(audits).toHaveLength(1);
|
||||||
|
expect(audits[0]?.diff).toMatchObject({ connectionId: gatedConn, pollId: poll?.id, opened: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("release: after the pass, amplify proceeds past the gate (stops at credentials, not the hold)", async () => {
|
||||||
|
const draftId = await boostDraft(gatedConn);
|
||||||
|
const [poll] = await publishPolls(draftId);
|
||||||
|
await service().amplifyDraft(ws, owner(), draftId, gatedConn, "repost");
|
||||||
|
await vote(poll!.id, admin2, "yes");
|
||||||
|
expect((await publishPolls(draftId))[0]?.state).toBe("passed");
|
||||||
|
|
||||||
|
// Past the gate the call reaches the connector, which fails here on the
|
||||||
|
// dummy vault credential — the assertion is that it no longer holds.
|
||||||
|
const outcome = await service().amplifyDraft(ws, owner(), draftId, gatedConn, "repost").then(
|
||||||
|
(result) => ({ held: typeof result === "object" && result !== null && (result as { state?: unknown }).state === "awaiting_publish_approval" }),
|
||||||
|
() => ({ held: false }),
|
||||||
|
);
|
||||||
|
expect(outcome.held).toBe(false);
|
||||||
|
expect(await heldAudits(draftId)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manual: amplify proceeds with no poll and no hold, as before", async () => {
|
||||||
|
const draftId = await boostDraft(manualConn);
|
||||||
|
expect(await publishPolls(draftId)).toEqual([]);
|
||||||
|
// Unset governance behaves exactly as before the gate existed: straight
|
||||||
|
// to the connector (dummy credential here, so it throws past the gate).
|
||||||
|
await expect(service().amplifyDraft(ws, owner(), draftId, manualConn, "repost")).rejects.toThrow();
|
||||||
|
expect(await publishPolls(draftId)).toEqual([]);
|
||||||
|
expect(await heldAudits(draftId)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -11,9 +11,10 @@ import {
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import type { Request } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { RbacGuard, RequireGroupAccess, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
|
import { RbacGuard, RequireGroupAccess, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
|
||||||
import { ApprovalGateService } from "./approval-gate.service.js";
|
import { ApprovalGateService } from "./approval-gate.service.js";
|
||||||
import type { GateDecisionRecord } from "../chatgov/gate.service.js";
|
import type { GateDecisionRecord } from "../chatgov/gate.service.js";
|
||||||
|
|
@ -149,10 +150,13 @@ export class PublishingController {
|
||||||
@Req() request: Request & AuthenticatedRequest,
|
@Req() request: Request & AuthenticatedRequest,
|
||||||
@Param("draftId") draftId: string,
|
@Param("draftId") draftId: string,
|
||||||
@Body() body: unknown,
|
@Body() body: unknown,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const session = requireSession(request);
|
const session = requireSession(request);
|
||||||
const dto = parseAmplifyDraft(body);
|
const dto = parseAmplifyDraft(body);
|
||||||
return this.publishing.amplifyDraft(session.workspaceId, actorFrom(session), parseId(draftId), dto.connectionId, dto.kind);
|
const result = await this.publishing.amplifyDraft(session.workspaceId, actorFrom(session), parseId(draftId), dto.connectionId, dto.kind);
|
||||||
|
if (typeof result === "object" && result !== null && (result as { state?: unknown }).state === "awaiting_publish_approval") res.status(202);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(":draftId/schedule")
|
@Post(":draftId/schedule")
|
||||||
|
|
|
||||||
|
|
@ -194,9 +194,13 @@ export class PublishingService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute an approved draft's amplification (repost/quote of the source post
|
* Execute an approved draft's amplification (repost/quote of the source post
|
||||||
* recorded at creation) through the given connection. The governance gate is
|
* recorded at creation) through the given connection. The governance gates
|
||||||
* the draft state: only `approved` drafts amplify, and the target is the
|
* are the draft state (only `approved` drafts amplify; the target is the
|
||||||
* source reference the draft carries — never a caller-supplied id.
|
* source reference the draft carries — never a caller-supplied id) and the
|
||||||
|
* connection's publish governance: a gated connection holds with
|
||||||
|
* `awaiting_publish_approval` until its publish poll for the current
|
||||||
|
* approved revision passes, and the holder re-invokes amplify after the
|
||||||
|
* pass (hold-and-retry).
|
||||||
*/
|
*/
|
||||||
async amplifyDraft(
|
async amplifyDraft(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
|
@ -223,6 +227,24 @@ export class PublishingService {
|
||||||
const connection = (await client.query(
|
const connection = (await client.query(
|
||||||
"SELECT provider, fields FROM connections WHERE id = $1", [connectionId])).rows[0];
|
"SELECT provider, fields FROM connections WHERE id = $1", [connectionId])).rows[0];
|
||||||
if (connection === undefined) throw new NotFoundException("connection not found");
|
if (connection === undefined) throw new NotFoundException("connection not found");
|
||||||
|
// Publish-phase gate: a gated connection amplifies only once the admins'
|
||||||
|
// publish poll for the current approved revision has passed. A held
|
||||||
|
// amplify opens (or reuses) the same poll the publish route uses; the
|
||||||
|
// holder re-invokes amplify after the pass.
|
||||||
|
const governance = readPublishGovernance(connection.fields);
|
||||||
|
if (isPublishGateMode(governance)) {
|
||||||
|
const held = await checkPublishGate(client, {
|
||||||
|
workspaceId, draftId, connectionId, mode: governance,
|
||||||
|
requestedBy: actor.userId, publishAt: new Date(), requiredPermission: "publication.schedule",
|
||||||
|
});
|
||||||
|
if (held.kind === "held") {
|
||||||
|
await this.audit(client, workspaceId, actor, "draft.amplify_held", draftId, {
|
||||||
|
connectionId, kind, target: source, publishGovernance: governance,
|
||||||
|
pollId: held.hold.pollId, opened: held.opened,
|
||||||
|
});
|
||||||
|
return { held: held.hold };
|
||||||
|
}
|
||||||
|
}
|
||||||
await this.audit(client, workspaceId, actor, "draft.amplify_requested", draftId,
|
await this.audit(client, workspaceId, actor, "draft.amplify_requested", draftId,
|
||||||
{ connectionId, kind, target: source });
|
{ connectionId, kind, target: source });
|
||||||
return {
|
return {
|
||||||
|
|
@ -231,6 +253,7 @@ export class PublishingService {
|
||||||
source,
|
source,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
if ("held" in gate) return gate.held;
|
||||||
|
|
||||||
// The network call stays outside the tenant transaction.
|
// The network call stays outside the tenant transaction.
|
||||||
const connection: ConnectorConnection = {
|
const connection: ConnectorConnection = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue