Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions graphql/server/src/middleware/__tests__/actor-entity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import type { NextFunction, Request, Response } from 'express';

import { ActorEntityError, createActorEntityResolver } from '../actor-entity';

const mockQuery = jest.fn();
const mockPgQueryContext = jest.fn();

jest.mock('pg-cache', () => ({
getPgPool: () => ({ query: mockQuery })
}));
jest.mock('pg-query-context', () => ({
__esModule: true,
default: (args: any) => mockPgQueryContext(args)
}));
jest.mock('@pgpmjs/env', () => ({ getNodeEnv: () => 'test' }));

// eslint-disable-next-line @typescript-eslint/no-var-requires
const { createAuthenticateMiddleware } = require('../auth');

describe('createActorEntityResolver', () => {
it('resolves and caches the entity pair per database + actor', async () => {
const query = jest.fn().mockResolvedValue({ entity_id: 'owner-1', entity_type: 'app' });
const resolve = createActorEntityResolver({ query, ttlMs: 1000, now: () => 0 });

await expect(resolve('db-1', 'actor-1')).resolves.toEqual({ entityId: 'owner-1', entityType: 'app' });
await expect(resolve('db-1', 'actor-1')).resolves.toEqual({ entityId: 'owner-1', entityType: 'app' });
expect(query).toHaveBeenCalledTimes(1);

await resolve('db-2', 'actor-1');
expect(query).toHaveBeenCalledTimes(2);
});

it('expires cached entries after the ttl', async () => {
let clock = 0;
const query = jest.fn().mockResolvedValue({ entity_id: 'owner-1', entity_type: 'org' });
const resolve = createActorEntityResolver({ query, ttlMs: 10, now: () => clock });

await resolve('db-1', 'actor-1');
clock = 11;
await resolve('db-1', 'actor-1');
expect(query).toHaveBeenCalledTimes(2);
});

it('throws when the actor does not resolve to an entity', async () => {
const resolve = createActorEntityResolver({ query: jest.fn().mockResolvedValue(undefined) });
await expect(resolve('db-1', 'ghost')).rejects.toBeInstanceOf(ActorEntityError);
});
});

describe('authenticate middleware entity attribution', () => {
const api = {
dbname: 'tenant_db',
databaseId: 'db-1',
rlsModule: {
authenticate: 'authenticate',
authenticateStrict: 'authenticate_strict',
privateSchema: { schemaName: 'app_private' }
}
};

const run = async (authorization?: string) => {
const middleware = createAuthenticateMiddleware({ pg: {}, server: {} } as any);
const req = {
api,
headers: authorization ? { authorization } : {},
get: (): string | undefined => undefined,
clientIp: '127.0.0.1'
} as unknown as Request;
const res = {
status: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis()
} as unknown as Response;
const next = jest.fn() as NextFunction;
await middleware(req, res, next);
return { req, res, next };
};

const PROBE = /to_regprocedure\('app_scope\.actor_entity/;
const USERS_MODULE = /FROM metaschema_modules_public\.users_module WHERE database_id = \$1/;
/** `present` — app_scope is deployed; `users` — this database installs a users module. */
const withEntityModel = (
{ present, users = present }: { present: boolean; users?: boolean },
row?: { entity_id: string; entity_type: string }
) =>
mockQuery.mockImplementation(async (sql: string) => {
if (PROBE.test(sql)) return { rows: [{ present }] };
if (USERS_MODULE.test(sql)) return { rows: [{ installed: users }] };
return { rows: row ? [row] : [] };
});
const entityCalls = () =>
mockQuery.mock.calls.filter(([sql]) => !PROBE.test(sql) && !USERS_MODULE.test(sql));

beforeEach(() => {
mockQuery.mockReset();
mockPgQueryContext.mockReset();
});

it('stamps the actor entity resolved through app_scope.actor_entity for a user token', async () => {
mockPgQueryContext.mockResolvedValue({ rowCount: 1, rows: [{ user_id: 'user-1', role: 'authenticated' }] });
withEntityModel({ present: true }, { entity_id: 'user-1', entity_type: 'app' });

const { req, next } = await run('Bearer tok');

expect(entityCalls()).toEqual([
[expect.stringContaining('app_scope.actor_entity($1, $2)'), ['db-1', 'user-1']]
]);
expect(req.actorEntity).toEqual({ entityId: 'user-1', entityType: 'app' });
expect(next).toHaveBeenCalled();
});

it("attributes a principal credential to its owner's entity pair", async () => {
mockPgQueryContext.mockResolvedValue({
rowCount: 1,
rows: [{ user_id: 'principal-1', principal_id: 'principal-1', kind: 'principal' }]
});
withEntityModel({ present: true }, { entity_id: 'org-1', entity_type: 'org' });

const { req } = await run('Bearer tok');

expect(entityCalls()).toEqual([[expect.any(String), ['db-1', 'principal-1']]]);
expect(req.actorEntity).toEqual({ entityId: 'org-1', entityType: 'org' });
});

it('fails the request instead of continuing entityless when the actor cannot be resolved', async () => {
mockPgQueryContext.mockResolvedValue({ rowCount: 1, rows: [{ user_id: 'user-1' }] });
withEntityModel({ present: true });

const { req, res, next } = await run('Bearer tok');

expect(req.actorEntity).toBeUndefined();
expect(next).not.toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
errors: [expect.objectContaining({ extensions: expect.objectContaining({ code: 'INTERNAL_FAILURE' }) })]
})
);
});

it('stamps nothing when the tenant database has no app_scope', async () => {
mockPgQueryContext.mockResolvedValue({ rowCount: 1, rows: [{ user_id: 'user-1' }] });
withEntityModel({ present: false });

const { req, next } = await run('Bearer tok');

expect(entityCalls()).toEqual([]);
expect(req.actorEntity).toBeUndefined();
expect(next).toHaveBeenCalled();
});

it('stamps nothing when app_scope is deployed but this database installs no users module', async () => {
mockPgQueryContext.mockResolvedValue({ rowCount: 1, rows: [{ user_id: 'user-1' }] });
withEntityModel({ present: true, users: false });

const { req, next } = await run('Bearer tok');

expect(mockQuery.mock.calls.some(([sql, params]) => USERS_MODULE.test(sql) && params[0] === 'db-1')).toBe(true);
expect(entityCalls()).toEqual([]);
expect(req.actorEntity).toBeUndefined();
expect(next).toHaveBeenCalled();
});

it('leaves anonymous requests without an actor entity', async () => {
const { req, next } = await run();

expect(mockQuery).not.toHaveBeenCalled();
expect(req.actorEntity).toBeUndefined();
expect(next).toHaveBeenCalled();
});
});
91 changes: 91 additions & 0 deletions graphql/server/src/middleware/actor-entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* actor-entity — Resolve the entity pair an authenticated actor works on
* behalf of.
*
* `entity_id` / `entity_type` are attribution, not authorization: they name
* the user or org that owns (and is billed for) work created in the request.
* The pair is resolved server-side through `app_scope.actor_entity`, which
* maps a principal credential to its owner — a principal is never an entity
* of its own. Callers cannot supply the pair on the public surface.
*
* A database has an entity model only when it installs the users module
* (`app_scope.actor_entity` types an actor by its users row). A database
* without one — a bare RLS module, an auth-only fixture — has no entity for
* an actor to carry, so no pair is stamped. Once the model is present, a
* session that cannot be attributed is a hard failure — work is never
* created entityless.
*/

import type { Pool } from 'pg';

export interface ActorEntity {
entityId: string;
entityType: string;
}

export type ActorEntityQuery = (
databaseId: string,
actorId: string
) => Promise<{ entity_id: string; entity_type: string } | undefined>;

export interface ActorEntityResolverOptions {
query: ActorEntityQuery;
ttlMs?: number;
now?: () => number;
}

const DEFAULT_TTL_MS = 60_000;

export class ActorEntityError extends Error {
readonly code = 'ACTOR_ENTITY_UNRESOLVED';
}

export const createActorEntityResolver = (opts: ActorEntityResolverOptions) => {
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
const now = opts.now ?? Date.now;
const cache = new Map<string, { value: ActorEntity; expiresAt: number }>();

return async (databaseId: string, actorId: string): Promise<ActorEntity> => {
const key = `${databaseId}:${actorId}`;
const hit = cache.get(key);
if (hit && hit.expiresAt > now()) {
return hit.value;
}
const row = await opts.query(databaseId, actorId);
if (!row?.entity_id || !row?.entity_type) {
throw new ActorEntityError(
`actor ${actorId} in database ${databaseId} does not resolve to an entity`
);
}
const value: ActorEntity = { entityId: row.entity_id, entityType: row.entity_type };
cache.set(key, { value, expiresAt: now() + ttlMs });
return value;
};
};

/**
* Whether `databaseId` has an entity model to attribute against: the
* `app_scope.actor_entity` resolver is deployed and the database installs a
* users module for it to read.
*/
export const hasEntityModel = async (pool: Pool, databaseId: string): Promise<boolean> => {
const result = await pool.query<{ present: boolean }>(
`SELECT to_regprocedure('app_scope.actor_entity(uuid, uuid)') IS NOT NULL
AND to_regclass('metaschema_modules_public.users_module') IS NOT NULL AS present`
);
if (result.rows[0]?.present !== true) return false;
const installed = await pool.query<{ installed: boolean }>(
'SELECT EXISTS (SELECT 1 FROM metaschema_modules_public.users_module WHERE database_id = $1) AS installed',
[databaseId]
);
return installed.rows[0]?.installed === true;
};

export const pgActorEntityQuery = (pool: Pool): ActorEntityQuery =>
async (databaseId, actorId) => {
const result = await pool.query<{ entity_id: string; entity_type: string }>(
'SELECT entity_id, entity_type FROM app_scope.actor_entity($1, $2)',
[databaseId, actorId]
);
return result.rows[0];
};
37 changes: 37 additions & 0 deletions graphql/server/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getPgPool } from 'pg-cache';
import pgQueryContext from 'pg-query-context';

import { respondWithGraphQLError } from '../errors/graphql-response';
import { createActorEntityResolver, hasEntityModel, pgActorEntityQuery } from './actor-entity';

const log = new Logger('auth');
const isDev = () => getNodeEnv() === 'development';
Expand All @@ -33,6 +34,35 @@ const parseCookieToken = (req: Request, cookieName: string): string | undefined
export const createAuthenticateMiddleware = (
opts: PgpmOptions
): RequestHandler => {
type Pool = Parameters<typeof pgActorEntityQuery>[0];
type Resolver = ReturnType<typeof createActorEntityResolver> | null;
// Whether a database has an entity model is re-probed on a TTL: a users
// module deployed while the server runs must start attributing without a
// restart, and a negative answer is never sticky.
const PROBE_TTL_MS = 60_000;
const resolvers = new Map<string, ReturnType<typeof createActorEntityResolver>>();
const probes = new Map<string, { resolver: Promise<Resolver>; probedAt: number }>();
const actorEntityResolver = (dbname: string, databaseId: string, pool: Pool): Promise<Resolver> => {
const key = `${dbname}:${databaseId}`;
const hit = probes.get(key);
if (hit && hit.probedAt + PROBE_TTL_MS > Date.now()) return hit.resolver;
const resolver = hasEntityModel(pool, databaseId).then((present) => {
if (!present) {
log.debug(`[auth] database ${databaseId} installs no entity model; no entity attribution stamped`);
return null;
}
let cached = resolvers.get(dbname);
if (!cached) {
cached = createActorEntityResolver({ query: pgActorEntityQuery(pool) });
resolvers.set(dbname, cached);
}
return cached;
});
resolver.catch(() => probes.delete(key));
probes.set(key, { resolver, probedAt: Date.now() });
return resolver;
};

return async (
req: Request,
res: Response,
Expand Down Expand Up @@ -122,6 +152,13 @@ export const createAuthenticateMiddleware = (

token = result.rows[0];
log.info(`[auth] Auth success: role=${token.role}, user_id=${token.user_id}`);

if (token?.user_id && api.databaseId) {
const resolve = await actorEntityResolver(api.dbname, api.databaseId, pool);
if (resolve) {
req.actorEntity = await resolve(api.databaseId, token.user_id);
}
}
} catch (e: any) {
log.error('[auth] Auth error:', e.message);
respondWithGraphQLError(
Expand Down
7 changes: 7 additions & 0 deletions graphql/server/src/middleware/graphile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,13 @@ const buildPreset = async (
// Principal identity — always set; equals user_id for human sessions
pgSettings['jwt.claims.principal_id'] = req.token.principal_id || req.token.user_id;

// Entity attribution — resolved server-side by the auth middleware;
// a principal credential resolves to its owner's entity pair
if (req.actorEntity) {
pgSettings['jwt.claims.entity_id'] = req.actorEntity.entityId;
pgSettings['jwt.claims.entity_type'] = req.actorEntity.entityType;
}

// Enforce read-only transactions for read_only credentials
if (req.token.access_level === 'read_only') {
pgSettings['default_transaction_read_only'] = 'on';
Expand Down
8 changes: 8 additions & 0 deletions graphql/server/src/middleware/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ApiStructure, ConstructiveAPIToken, RequestProtection } from '@constructive-io/express-context';

import type { ActorEntity } from './actor-entity';

export type { ConstructiveAPIToken } from '@constructive-io/express-context';

declare global {
Expand All @@ -11,6 +13,12 @@ declare global {
databaseId?: string;
requestId?: string;
token?: ConstructiveAPIToken;
/**
* Entity the authenticated actor works on behalf of, resolved
* server-side via `app_scope.actor_entity`. Set by the auth
* middleware for token sessions; never taken from the client.
*/
actorEntity?: ActorEntity;
/** Device token from constructive_device_token cookie for trusted device tracking */
deviceToken?: string;
/**
Expand Down
Loading