Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
83
apps/api/src/rbac/rbac.guard.ts
Normal file
83
apps/api/src/rbac/rbac.guard.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { BadRequestException, CanActivate, ExecutionContext, Inject, Injectable, SetMetadata, UnauthorizedException, ForbiddenException } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { verifySession, epochValid, type SessionPayload } from "../auth/session.js";
|
||||
import { pool, withTenant } from "../db/client.js";
|
||||
import { userCanUseConnection } from "../groups/groups.service.js";
|
||||
import { isUuid } from "../http/validation.js";
|
||||
|
||||
export const PERMISSION_KEY = "required_permission";
|
||||
export const GROUP_ACCESS_KEY = "required_group_access";
|
||||
|
||||
export const RequirePermission = (permission: string): MethodDecorator =>
|
||||
SetMetadata(PERMISSION_KEY, permission) as MethodDecorator;
|
||||
|
||||
/**
|
||||
* Additionally gate a route on group scope for its `:connectionId` param:
|
||||
* Owner/Admin pass; anyone else must belong to a group the connection is
|
||||
* assigned to. Fails closed if the route has no `:connectionId`.
|
||||
*/
|
||||
export const RequireGroupAccess = (source: "params" | "body" = "params"): MethodDecorator =>
|
||||
SetMetadata(GROUP_ACCESS_KEY, source) as MethodDecorator;
|
||||
|
||||
export interface AuthenticatedRequest {
|
||||
session?: SessionPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* RBAC guard: session signature -> workspace membership -> permission set.
|
||||
* Epoch-fenced (finding #2): revocation bumps authz_epoch, session fails closed.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RbacGuard implements CanActivate {
|
||||
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const required = this.reflector.get<string>(PERMISSION_KEY, context.getHandler());
|
||||
const http = context.switchToHttp();
|
||||
const req = http.getRequest<{ headers: Record<string, string>; params?: Record<string, string>; body?: unknown } & AuthenticatedRequest>();
|
||||
const auth = req.headers["authorization"];
|
||||
if (auth === undefined || !auth.startsWith("Bearer ")) throw new UnauthorizedException();
|
||||
const session = verifySession(auth.slice("Bearer ".length));
|
||||
if (session === null) throw new UnauthorizedException();
|
||||
|
||||
const ws = await pool.query("SELECT authz_epoch FROM workspaces WHERE id = $1", [session.workspaceId]);
|
||||
// authz_epoch is bigint: node-postgres returns it as a string, so normalise
|
||||
// before the strict comparison or every session reads as stale.
|
||||
const rawEpoch = ws.rows[0]?.authz_epoch as string | number | undefined;
|
||||
if (rawEpoch === undefined || !epochValid(session, Number(rawEpoch))) {
|
||||
throw new UnauthorizedException("authorization epoch stale — re-authenticate");
|
||||
}
|
||||
|
||||
if (required !== undefined) {
|
||||
// memberships is FORCE RLS: the lookup must run inside the session's
|
||||
// tenant context, otherwise sp_app sees zero rows and every check fails.
|
||||
const allowed = await withTenant(session.workspaceId, (client) =>
|
||||
client.query(
|
||||
`SELECT 1 FROM role_permissions rp
|
||||
JOIN memberships m ON m.role = rp.role AND m.workspace_id = $1 AND m.user_id = $2 AND m.status = 'active'
|
||||
WHERE rp.permission = $3`,
|
||||
[session.workspaceId, session.userId, required],
|
||||
),
|
||||
);
|
||||
if (allowed.rowCount === 0) throw new ForbiddenException(`missing permission: ${required}`);
|
||||
}
|
||||
|
||||
const groupSource = this.reflector.get<"params" | "body" | undefined>(GROUP_ACCESS_KEY, context.getHandler());
|
||||
if (groupSource !== undefined) {
|
||||
const body = req.body;
|
||||
const connectionId = groupSource === "body"
|
||||
? (typeof body === "object" && body !== null && "connectionId" in body ? body.connectionId : undefined)
|
||||
: req.params?.connectionId;
|
||||
if (connectionId === undefined || connectionId === "") {
|
||||
throw new ForbiddenException("group access check requires a connectionId");
|
||||
}
|
||||
if (typeof connectionId !== "string" || !isUuid(connectionId)) throw new BadRequestException("connectionId must be a UUID");
|
||||
const permitted = await withTenant(session.workspaceId, (client) =>
|
||||
userCanUseConnection(client, session.workspaceId, session.userId, connectionId),
|
||||
);
|
||||
if (!permitted) throw new ForbiddenException("connection is not shared with any of your groups");
|
||||
}
|
||||
req.session = session;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue