Skip to content
Merged
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
24 changes: 24 additions & 0 deletions server/src/auth/organization-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Request } from 'express';

export class ConflictingOrganizationSelectionError extends Error {}

/** Every explicit selector must agree with the authenticated provider selection.
* Request fields cannot switch an organization-bound credential to another org.
* Neither memberships nor caches pick an organization. */
export function selectedOrganizationForAuthentication(
req: Partial<Pick<Request, 'headers' | 'query' | 'body' | 'params'>>,
providerOrg?: string,
): string | null {
const selectors = [
providerOrg,
req.headers?.['x-organization-id'], req.query?.org, req.query?.org_id, req.query?.organization_id, req.query?.organizationId,
req.body?.org_id, req.body?.organization_id, req.body?.organizationId,
req.params?.org_id, req.params?.orgId, req.params?.organizationId,
].filter((value) => value !== undefined);
if (selectors.some((value) => typeof value !== 'string' || !value.trim())) {
throw new ConflictingOrganizationSelectionError();
}
const ids = new Set(selectors.map((value) => (value as string).trim()));
if (ids.size > 1) throw new ConflictingOrganizationSelectionError();
return ids.values().next().value ?? null;
}
21 changes: 2 additions & 19 deletions server/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getBearerToken, getWorkOSApiKeyToken, isWorkOSApiKeyFormat } from './ap
import { verifyWorkOSJWT, looksLikeJWT, isInvalidWorkOSJWTError, WorkOSJWTUnavailableError } from '../auth/workos-jwt.js';
import { storeRefreshedSession, getRefreshedSession, cleanExpiredRefreshes } from '../db/session-refresh-db.js';
import { loadAuthorizationSnapshot, AuthorizationSnapshotUnavailableError } from '../db/user-authorization-snapshot-db.js';
import { ConflictingOrganizationSelectionError, selectedOrganizationForAuthentication } from '../auth/organization-selection.js';
import { getOrganizationAuthorizationUserId } from '../auth/organization-principal.js';
import { loadApiKeyManagementSnapshot } from '../db/api-key-management-db.js';

Expand Down Expand Up @@ -782,25 +783,7 @@ export function invalidateSessionsForUsers(workosUserIds: string[]): void {
}

class InvalidAuthorizationCredentialError extends Error {}
export class ConflictingOrganizationSelectionError extends Error {}

/** Every explicit selector must agree with the authenticated provider selection.
* Request fields cannot switch an organization-bound credential to another org.
* Neither memberships nor caches pick an organization. */
export function selectedOrganizationForAuthentication(req: Request, providerOrg?: string): string | null {
const selectors = [
providerOrg,
req.headers['x-organization-id'], req.query?.org, req.query?.organization_id, req.query?.organizationId,
req.body?.organization_id, req.body?.organizationId,
req.params?.orgId, req.params?.organizationId,
].filter((value) => value !== undefined);
if (selectors.some((value) => typeof value !== 'string' || !value.trim())) {
throw new ConflictingOrganizationSelectionError();
}
const ids = new Set(selectors.map((value) => (value as string).trim()));
if (ids.size > 1) throw new ConflictingOrganizationSelectionError();
return ids.values().next().value ?? null;
}
export { ConflictingOrganizationSelectionError, selectedOrganizationForAuthentication } from '../auth/organization-selection.js';

/** Hydrate a new request object; never mutate the provider object in a cache. */
async function hydrateAuthenticatedUser(
Expand Down
52 changes: 25 additions & 27 deletions server/src/routes/community-mirrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { respondToAdminAuthorizationError } from '../auth/admin-authorization-re
*
* GET /api/registry/mirrors — list mirrors (public, with etags)
* GET /api/registry/mirrors/:platform — read one mirror (public)
* PUT /api/registry/mirrors/:platform — publish for moderators, propose for other callers
* PUT /api/registry/mirrors/:platform — publish with admin keys, propose for other callers
* GET /api/registry/mirror-proposals — review queue or caller's own proposals
* POST /api/registry/mirror-proposals/:id/{approve,reject} — moderation
*
Expand All @@ -27,8 +27,7 @@ import type { CommunityMirror, CommunityMirrorProposal } from '../db/community-m
import { PublisherDatabase } from '../db/publisher-db.js';
import type { CatalogEventsDatabase } from '../db/catalog-events-db.js';
import { getClient } from '../db/client.js';
import { isRegistryModerator } from '../services/brand-logo-auth.js';
import { isAuthenticatedUserAAOAdmin, type AAOAdminPrincipal } from '../addie/admin-status-lookup.js';
import type { AAOAdminPrincipal } from '../addie/admin-status-lookup.js';
import { validateAdagentsDocument } from '../services/adagents-schema-validator.js';
import { registryReadRateLimiter, brandCreationRateLimiter } from '../middleware/rate-limit.js';
import { createLogger } from '../logger.js';
Expand Down Expand Up @@ -84,18 +83,13 @@ export interface CommunityMirrorRouterConfig {
}

/**
* Organization API keys represent an organization, not a human working-group
* member, so they intentionally submit proposals instead of passing this gate.
* Human management stays disabled until the registry management gate has a
* separately reviewed authorization-snapshot integration.
* Only the authenticated platform admin API key retains management access;
* organization API keys intentionally submit proposals instead.
*/
async function canManageMirrors(user: AAOAdminPrincipal): Promise<boolean> {
const userId = user.authWorkosUserId ?? user.id;
if (userId === 'admin_api_key') return true;
if (userId.startsWith('api_key_')) return false;
const [isOrganizationAdmin, isModerator] = await Promise.all([
isAuthenticatedUserAAOAdmin(user),
isRegistryModerator(userId),
]);
return isOrganizationAdmin || isModerator;
function canManageMirrors(user: AAOAdminPrincipal): boolean {
return (user.authWorkosUserId ?? user.id) === 'admin_api_key';
}

async function resolveManager(
Expand All @@ -107,9 +101,9 @@ async function resolveManager(
res.status(401).json({ error: 'Authentication required' });
return null;
}
if (!(await canManageMirrors(req.user!))) {
if (!canManageMirrors(req.user!)) {
res.status(403).json({
error: 'Only registry moderators or AgenticAdvertising.org administrators can manage community mirrors',
error: 'Community mirror management requires a platform admin API key',
});
return null;
}
Expand All @@ -119,7 +113,8 @@ async function resolveManager(
async function callerOrganizationId(req: Request, res: Response): Promise<string | null | undefined> {
try {
const attached = (req as Request & { apiKey?: { organizationId?: string } }).apiKey?.organizationId;
return attached ?? await resolveCallerOrgId(req);
if (req.user?.id.startsWith('api_key_') && attached) return attached;
return await resolveCallerOrgId(req);
} catch (error) {
if (sendCallerOrganizationAuthError(error, res)) return undefined;
throw error;
Expand Down Expand Up @@ -212,12 +207,15 @@ export function createCommunityMirrorRouter(config: CommunityMirrorRouterConfig)
if (req.query.review_queue !== undefined && !['true', 'false'].includes(String(req.query.review_queue))) {
return res.status(400).json({ error: 'Invalid review_queue flag' });
}
const isManager = await canManageMirrors(req.user!);
const isManager = canManageMirrors(req.user!);
if (reviewQueue && !isManager) {
return res.status(403).json({ error: 'Registry moderator access is required for the review queue' });
return res.status(403).json({ error: 'The review queue requires a platform admin API key' });
}
const organizationId = isManager ? null : await callerOrganizationId(req, res);
if (organizationId === undefined) return;
if (!isManager && !organizationId) {
return res.status(404).json({ error: 'Community mirror proposal not found' });
}
const limit = req.query.limit ? parseInt(String(req.query.limit), 10) : undefined;
const offset = req.query.offset ? parseInt(String(req.query.offset), 10) : undefined;
try {
Expand All @@ -228,7 +226,6 @@ export function createCommunityMirrorRouter(config: CommunityMirrorRouterConfig)
| 'rejected'
| undefined,
proposedByOrganizationId: isManager ? undefined : organizationId ?? undefined,
proposedByUserId: isManager || organizationId ? undefined : userId,
limit: Number.isFinite(limit) ? limit : undefined,
offset: Number.isFinite(offset) ? offset : undefined,
});
Expand All @@ -247,14 +244,15 @@ export function createCommunityMirrorRouter(config: CommunityMirrorRouterConfig)
return res.status(400).json({ error: 'Invalid proposal identifier' });
}
try {
const proposal = await mirrorDb.getProposalById(proposalId);
if (!proposal) return res.status(404).json({ error: 'Community mirror proposal not found' });
const isManager = await canManageMirrors(req.user!);
const isManager = canManageMirrors(req.user!);
const organizationId = isManager ? null : await callerOrganizationId(req, res);
if (organizationId === undefined) return;
const ownsProposal = organizationId
? proposal.proposed_by_organization_id === organizationId
: proposal.proposed_by_user_id === userId;
if (!isManager && !organizationId) {
return res.status(404).json({ error: 'Community mirror proposal not found' });
}
const proposal = await mirrorDb.getProposalById(proposalId);
if (!proposal) return res.status(404).json({ error: 'Community mirror proposal not found' });
const ownsProposal = organizationId !== null && proposal.proposed_by_organization_id === organizationId;
if (!ownsProposal && !isManager) {
return res.status(404).json({ error: 'Community mirror proposal not found' });
}
Expand Down Expand Up @@ -464,7 +462,7 @@ export function createCommunityMirrorRouter(config: CommunityMirrorRouterConfig)

const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: 'Authentication required' });
const isManager = await canManageMirrors(req.user!);
const isManager = canManageMirrors(req.user!);
const organizationId = isManager ? null : await callerOrganizationId(req, res);
if (organizationId === undefined) return;
if (!isManager && !organizationId) {
Expand Down
49 changes: 37 additions & 12 deletions server/src/routes/helpers/resolve-caller-org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
* against the WorkOS JWKS endpoint.
* 2. WorkOS API key (sk_* / wos_api_key_* prefixes) — server-to-server
* integrations. Validated via the existing `validateWorkOSApiKey` helper.
* 3. Cookie session — a web session unsealed in `optionalAuth`, producing
* `req.user`. With no Bearer header, the legacy read-model fallback uses
* `resolvePrimaryOrganization` and organization_memberships.
* 3. Sealed cookie session — authentication middleware supplies a fresh
* authorization snapshot. Only an explicitly selected organization
* authorized for the exact credential is returned; primary orgs are ignored.
*
* An explicit Bearer must supply its own verified organization. Opaque/native
* session Bearers cannot use `req.user` to select an implicit primary organization.
Expand All @@ -21,10 +21,12 @@ import { getBearerToken, isWorkOSApiKeyFormat } from '../../middleware/api-key-f
import {
ConflictingOrganizationSelectionError,
selectedOrganizationForAuthentication,
validateWorkOSApiKey,
} from '../../middleware/auth.js';
} from '../../auth/organization-selection.js';
import { validateWorkOSApiKey } from '../../middleware/auth.js';
import { isInvalidWorkOSJWTError, unavailableJWTKeyService, WorkOSJWTUnavailableError } from '../../auth/workos-jwt.js';
import { resolvePrimaryOrganization } from '../../db/users-db.js';
import { getAuthorizationEnforcementWorkos } from '../../auth/workos-client.js';
import { getOrganizationAuthorizationUserId, type OrgAuthorizationPrincipal } from '../../auth/organization-principal.js';
import { resolveUserOrgAuthorization } from '../../utils/resolve-user-org-authorization.js';
import { createLogger } from '../../logger.js';

const logger = createLogger('resolve-caller-org');
Expand All @@ -49,7 +51,9 @@ function jwksForIssuer(iss: string): { jwks: JWTVerifyGetKey; clientId: string }
return { jwks, clientId };
}

export type MinimalReq = Pick<Request, 'headers'> & { user?: { id?: string } };
export type MinimalReq = Pick<Request, 'headers'> & Partial<Pick<Request, 'query' | 'body' | 'params'>> & {
user?: Partial<OrgAuthorizationPrincipal>;
};

export class CallerOrganizationAuthError extends Error {
constructor(readonly status: 401 | 403 | 503) {
Expand Down Expand Up @@ -128,12 +132,33 @@ async function resolveBearerOrganization(req: MinimalReq): Promise<ResolvedBeare
}
}

async function resolveCookieOrganization(userId: string | undefined): Promise<string | null> {
if (userId) {
/** Missing, denied, stale or unavailable cookie authority is public-only.
* Nullable consumers must never reinterpret it as canonical-user ownership. */
async function resolveCookieOrganization(req: MinimalReq): Promise<string | null> {
const user = req.user;
if (user?.id && user.id !== 'admin_api_key' && !user.id.startsWith('api_key_')) {
const principal: OrgAuthorizationPrincipal = {
id: user.id,
authWorkosUserId: user.authWorkosUserId,
authorizationSnapshot: user.authorizationSnapshot,
};
const snapshot = principal.authorizationSnapshot;
if (!snapshot || snapshot.authenticatedUserId !== getOrganizationAuthorizationUserId(principal)
|| snapshot.canonicalUserId !== principal.id) return null;
try {
return await resolvePrimaryOrganization(userId);
// Route params may become available only after authentication middleware.
// Reuse its conflict policy before consuming the persisted selection.
const organizationId = selectedOrganizationForAuthentication(req, snapshot.selectedOrganizationId ?? undefined);
if (!organizationId) return null;
// A snapshot selection is not a grant. This resolver rechecks exact
// membership/grant and brackets WorkOS reads with fresh snapshot checks.
const authorization = await resolveUserOrgAuthorization(
getAuthorizationEnforcementWorkos(), principal, organizationId,
);
return authorization.status === 'authorized' ? authorization.membership.organizationId : null;
} catch (err) {
logger.warn({ err, userId }, 'caller org resolution failed — falling back to public-only');
logger.warn({ err }, 'Explicit caller organization authorization unavailable or conflicting');
return null;
}
}

Expand All @@ -147,7 +172,7 @@ async function resolveCookieOrganization(userId: string | undefined): Promise<st
*/
export async function resolveCallerOrgId(req: MinimalReq): Promise<string | null> {
const authorization = req.headers.authorization;
if (getBearerToken(authorization) === null) return resolveCookieOrganization(req.user?.id);
if (getBearerToken(authorization) === null) return resolveCookieOrganization(req);

// Capture the credential and selection before any await. Later request
// mutation cannot substitute another key or erase an organization conflict.
Expand Down
Loading
Loading