feat: per-connection publish governance — auto, 1-admin, majority, consensus
Per the owner's spec: each social connection gets publishGovernance in its fields — unset = manual publish (today), auto = dispatch on approval, admin1/admin_majority/admin_consensus = a publish-phase chatgov poll (subject draft_publish, its own open-poll slot, no collision with review gates). Dispatch holds until the publish poll passes (E3 pre-dispatch check); auto dispatches on approval. Includes migration 033, the PATCH /connections/:id/publish-governance route (chatgov.manage), and 19+31 new tests. Implementation by the branding-panel process (claude/opus), lint and integration fixes here.
This commit is contained in:
parent
f2eb14956e
commit
b89dea5c93
26 changed files with 1637 additions and 31 deletions
|
|
@ -6,6 +6,7 @@ import { withTenant } from "../db/client.js";
|
|||
import type { SealedCredential } from "../vault/vault.js";
|
||||
import type { ConnectionSummary } from "./connections.service.js";
|
||||
import { scrub } from "../audit/audit.service.js";
|
||||
import { readPublishGovernance, type PublishGovernance } from "../publishing/publish-governance.js";
|
||||
|
||||
export interface StoredConnection { connection: ConnectorConnection; credential: SealedCredential | null }
|
||||
export interface ConnectionStore {
|
||||
|
|
@ -15,6 +16,8 @@ export interface ConnectionStore {
|
|||
save(session: SessionPayload, connection: ConnectorConnection, identity: NormalizedIdentity, credential: SealedCredential, reconnect: boolean): Promise<void>;
|
||||
rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void>;
|
||||
remove(session: SessionPayload, id: string, revoke: (stored: StoredConnection) => Promise<void>): Promise<void>;
|
||||
/** Set (or clear with null) connection.fields.publishGovernance; chatgov.manage holders only. */
|
||||
setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<void>;
|
||||
}
|
||||
|
||||
export class PgConnectionStore implements ConnectionStore {
|
||||
|
|
@ -65,7 +68,9 @@ export class PgConnectionStore implements ConnectionStore {
|
|||
if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException();
|
||||
const fields = { ...connection.fields, handle: identity.handle, displayName: identity.displayName ?? identity.handle, remoteId: identity.remoteId };
|
||||
if (reconnect) {
|
||||
const updated = await client.query("UPDATE connections SET fields = $2, disconnected_at = NULL WHERE id = $1 AND provider = $3 RETURNING id", [connection.id, fields, connection.provider]);
|
||||
// Governance settings are not provider identity: a reconnect keeps them.
|
||||
const updated = await client.query(`UPDATE connections SET fields = $2::jsonb || jsonb_strip_nulls(jsonb_build_object('publishGovernance', fields->'publishGovernance')),
|
||||
disconnected_at = NULL WHERE id = $1 AND provider = $3 RETURNING id`, [connection.id, fields, connection.provider]);
|
||||
if (!updated.rowCount) throw new NotFoundException("Connection removed during authorization");
|
||||
} else {
|
||||
await client.query("INSERT INTO connections (id, workspace_id, provider, channel_id, fields) VALUES ($1,$2,$3,$4,$5)", [connection.id, session.workspaceId, connection.provider, connection.channelId, fields]);
|
||||
|
|
@ -106,6 +111,22 @@ export class PgConnectionStore implements ConnectionStore {
|
|||
catch { await this.transaction(session.workspaceId, (client) => client.query("SELECT audit_append($1,$2,$3,'connection.revoke_failed','connection',$4,NULL)", [session.workspaceId, session.userId, session.role, id])); }
|
||||
}
|
||||
}
|
||||
async setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<void> {
|
||||
await this.transaction(session.workspaceId, async (client) => {
|
||||
// Serialized with membership revocation like every governance write.
|
||||
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [session.workspaceId]);
|
||||
const allowed = await client.query(`SELECT 1 FROM memberships m JOIN workspaces w ON w.id = m.workspace_id JOIN role_permissions rp ON rp.role = m.role WHERE m.user_id = $1 AND m.status = 'active' AND w.authz_epoch = $2 AND rp.permission = 'chatgov.manage'`, [session.userId, session.authzEpoch]);
|
||||
if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException();
|
||||
const current = await client.query("SELECT fields FROM connections WHERE id = $1 AND disconnected_at IS NULL FOR UPDATE", [id]);
|
||||
if (!current.rowCount) throw new NotFoundException("Connection not found");
|
||||
const previous = readPublishGovernance(current.rows[0]?.fields);
|
||||
await client.query(
|
||||
"UPDATE connections SET fields = CASE WHEN $2::text IS NULL THEN fields - 'publishGovernance' ELSE fields || jsonb_build_object('publishGovernance', $2::text) END WHERE id = $1",
|
||||
[id, mode],
|
||||
);
|
||||
await client.query("SELECT audit_append($1,$2,$3,'connection.publish_governance','connection',$4,$5)", [session.workspaceId, session.userId, session.role, id, { from: previous, to: mode }]);
|
||||
});
|
||||
}
|
||||
async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> {
|
||||
await this.transaction(workspaceId, async (client) => {
|
||||
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { Body, Controller, Delete, Get, Inject, Param, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
|
||||
import { BadRequestException, Body, Controller, Delete, Get, Inject, Param, Patch, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
|
||||
import type { Request, Response } from "express";
|
||||
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
|
||||
import { optionalString, parseId, requireObject, requireSession, requireString } from "../http/validation.js";
|
||||
import { ConnectionsService, type ConnectionSummary } from "./connections.service.js";
|
||||
import { AUTH_TTL_MS, stateCookieName, validState } from "./pending.js";
|
||||
import { CHATGOV_PERMISSIONS } from "../chatgov/policy.js";
|
||||
import { PUBLISH_GOVERNANCE_MODES, type PublishGovernance } from "../publishing/publish-governance.js";
|
||||
|
||||
@Controller("connections")
|
||||
export class ConnectionsController {
|
||||
|
|
@ -74,6 +76,19 @@ export class ConnectionsController {
|
|||
return this.redirectResponse(response, await this.connections.reconnect(requireSession(request), parseId(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish governance for one connection: auto | admin1 | admin_majority |
|
||||
* admin_consensus, or null for manual publishing. Deciding who must sign
|
||||
* off a publish is a governance rule, so it needs chatgov.manage.
|
||||
*/
|
||||
@Patch(":connectionId/publish-governance")
|
||||
@UseGuards(RbacGuard)
|
||||
@RequirePermission(CHATGOV_PERMISSIONS.manage)
|
||||
async publishGovernance(@Req() request: Request & AuthenticatedRequest, @Param("connectionId") id: string, @Body() body: unknown) {
|
||||
const input = requireObject(body, "publish governance");
|
||||
return this.connections.setPublishGovernance(requireSession(request), parseId(id), parsePublishGovernance(input.publishGovernance));
|
||||
}
|
||||
|
||||
@Delete(":connectionId")
|
||||
@UseGuards(RbacGuard)
|
||||
@RequirePermission("connection.manage")
|
||||
|
|
@ -82,3 +97,10 @@ export class ConnectionsController {
|
|||
return { removed: true, token };
|
||||
}
|
||||
}
|
||||
|
||||
function parsePublishGovernance(value: unknown): PublishGovernance | null {
|
||||
if (value === null) return null;
|
||||
const mode = PUBLISH_GOVERNANCE_MODES.find((candidate) => candidate === value);
|
||||
if (mode === undefined) throw new BadRequestException(`publishGovernance must be null or one of: ${PUBLISH_GOVERNANCE_MODES.join(", ")}`);
|
||||
return mode;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { RbacGuard } from "../rbac/rbac.guard.js";
|
|||
import { logger } from "../telemetry/logger.js";
|
||||
import { ConnectionsController } from "./connections.controller.js";
|
||||
import { CONNECTION_DEPENDENCIES, ConnectionsService, type ConnectionDependencies, type ConnectionSummary } from "./connections.service.js";
|
||||
import type { PublishGovernance } from "../publishing/publish-governance.js";
|
||||
import type { ConnectionStore, StoredConnection } from "./connection-store.js";
|
||||
import { RedisPendingStore } from "./pending.js";
|
||||
import { MemoryPendingRedis } from "./testing.js";
|
||||
|
|
@ -50,6 +51,14 @@ class MemoryConnections implements ConnectionStore {
|
|||
this.rows.delete(id);
|
||||
this.audit.push({ action: "connection.remove" });
|
||||
}
|
||||
async setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<void> {
|
||||
await this.assertAuthorized(session);
|
||||
const row = await this.get(session.workspaceId, id);
|
||||
const fields = { ...row.connection.fields };
|
||||
delete fields.publishGovernance;
|
||||
row.connection.fields = mode === null ? fields : { ...fields, publishGovernance: mode };
|
||||
this.audit.push({ action: "connection.publish_governance" });
|
||||
}
|
||||
async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> {
|
||||
const row = await this.get(workspaceId, id);
|
||||
if (row.credential?.ciphertext.equals(previous.ciphertext)) {
|
||||
|
|
@ -333,6 +342,21 @@ describe("HTTP OAuth connect with real connectors and mock authorization server"
|
|||
bridgeConfigured = false;
|
||||
});
|
||||
|
||||
it("sets, validates and clears per-connection publish governance", async () => {
|
||||
await finish(await begin());
|
||||
const id = (await listed())[0]?.id ?? "";
|
||||
const set = await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: "admin_majority" });
|
||||
expect(set.status).toBe(200);
|
||||
expect(await set.json()).toEqual({ connectionId: id, publishGovernance: "admin_majority" });
|
||||
expect((await listed())[0]?.fields.publishGovernance).toBe("admin_majority");
|
||||
expect(store.audit.at(-1)?.action).toBe("connection.publish_governance");
|
||||
expect((await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: "everyone" })).status).toBe(400);
|
||||
expect((await api(`/${id}/publish-governance`, "PATCH", {})).status).toBe(400);
|
||||
expect((await api("/not-a-uuid/publish-governance", "PATCH", { publishGovernance: "auto" })).status).toBe(400);
|
||||
expect((await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: null })).status).toBe(200);
|
||||
expect((await listed())[0]?.fields).not.toHaveProperty("publishGovernance");
|
||||
});
|
||||
|
||||
it("does not resurrect a connection removed while reconnect consent is pending", async () => {
|
||||
await finish(await begin());
|
||||
const row = (await listed())[0];
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { connectionRegistry } from "./connector-registry.js";
|
|||
import { PgConnectionStore, type ConnectionStore, type StoredConnection } from "./connection-store.js";
|
||||
import { AUTH_TTL_MS, browserHash, randomState, RedisPendingStore, type PendingStore } from "./pending.js";
|
||||
import { oauthFetch } from "./oauth-fetch.js";
|
||||
import type { PublishGovernance } from "../publishing/publish-governance.js";
|
||||
|
||||
export type ConnectionHealth = "healthy" | "needs_credentials" | "unavailable";
|
||||
export interface ConnectionSummary {
|
||||
|
|
@ -214,6 +215,11 @@ export class ConnectionsService {
|
|||
}
|
||||
return rows;
|
||||
}
|
||||
/** Per-connection publish governance (publish-governance.ts); null = manual. */
|
||||
async setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<{ connectionId: string; publishGovernance: PublishGovernance | null }> {
|
||||
await this.deps.store.setPublishGovernance(session, id, mode);
|
||||
return { connectionId: id, publishGovernance: mode };
|
||||
}
|
||||
async remove(session: SessionPayload, id: string): Promise<string> {
|
||||
await this.deps.store.assertAuthorized(session);
|
||||
await this.deps.store.remove(session, id, async (stored) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue