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
10 changes: 9 additions & 1 deletion docs/aao/aao-admins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,15 @@ Site-admin access is granted through membership in the server-pinned `aao-admin`

`ADMIN_EMAILS` is an environment-managed break-glass path. It is not shown or edited in the UI. Keep its list minimal, rotate it through the deployment-secret process, and remove emergency entries as soon as the incident is resolved. When removing an entry, deploy the changed environment to every application replica and verify that the former address can no longer use an admin route.

Addie web and voice administrator checks use the WorkOS credential that actually signed in, including its own email for break-glass access. Linking identities does not transfer platform administration: an administrator signing in through a linked non-admin canonical profile retains access, while a non-admin credential linked to an administrator does not inherit it. Organization owners cannot list or resolve the platform-wide escalation queue.

When the administrator lookup is unavailable, Addie web requests return a retryable `503` with `admin_authorization_unavailable`; an already-open Addie stream reports a recoverable error. This is separate from `403` for a confirmed denial. A separately configured break-glass email remains valid during a membership lookup outage. Voice calls require captured credential provenance; legacy calls must restart through sign-in.

Voice callbacks also require a signed session capability bound to the server's conversation record. A thread ID supplied in conversation text grants no access. Ending a call revokes that capability before contacting the video provider; expired, revoked, or unverified calls require a new session. Rotate `TAVUS_LLM_SECRET` consistently across replicas; rotation invalidates existing call capabilities.

Every site-admin grant or revoke requires a written reason and writes an append-only event with the actor, target, access mechanism, reason, and timestamp. The revoke and its audit event commit together, so deleting the active membership cannot delete its forensic record.

The replica that performs a grant or revoke clears that user's local admin-status cache immediately. Other replicas cache a positive `aao-admin` membership decision for at most 60 seconds, so emergency revocation takes effect there within that bound. Negative and failed checks remain fail-closed.
Admin authorization results are not cached. A grant or revoke is visible to every replica on its next authorization check, without a positive-cache propagation window. Failed checks remain fail-closed and are reported as unavailable rather than reused as either a grant or denial.

## Escalation triage

Expand All @@ -34,6 +40,8 @@ Members and visitors can ask Addie to escalate when she can't help. Escalations

## Editorial moderation (perspectives)

Addie's committee-leader review powers also belong to the authenticated credential. Linking to a committee leader's profile does not grant publishing, approval, rejection, revision, or pending-review access. Personal author attribution remains associated with the person's profile.

- **Review queue.** Web UI at `/admin/perspectives` shows `pending_review`. Or ask Addie *"list pending content"* (`list_pending_content`).
- **Approve / decline.** *"Approve perspective [slug]"* (`approve_content`), *"reject perspective [slug] with reason ..."* (`reject_content`).
- **Test posts.** Test perspectives published bypassing review (incident #271) come from the older publish path; the canonical path is `propose_content` → review → approve. If you see test content live without review, file an incident — that's a real bug, not a workflow.
Expand Down
43 changes: 10 additions & 33 deletions server/src/addie/admin-status-cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Admin-status cache primitives for both Slack and web admin lookups.
* Compatibility invalidators for former AAO-admin authorization caches.
*
* Extracted from `mcp/admin-tools.ts` so callers that only need to
* invalidate (route handlers, services, account/membership flows) don't
Expand All @@ -9,51 +9,28 @@
* stops admin-tools from being a chokepoint that pulls Anthropic into
* unrelated dependency chains.
*
* The cache values themselves stay private here — only invalidation is
* exported. Read paths still live in admin-tools.ts where they belong.
* AAO-admin authorization is deliberately uncached so grants and revocations
* are visible on every replica immediately. These functions remain as no-ops
* while their distributed callers migrate.
*/

const slackAdminStatusCache = new Map<string, { isAdmin: boolean; expiresAt: number }>();
const webAdminStatusCache = new Map<string, { isAdmin: boolean; expiresAt: number }>();

export function getSlackAdminStatusCache(): Map<string, { isAdmin: boolean; expiresAt: number }> {
return slackAdminStatusCache;
}

export function getWebAdminStatusCache(): Map<string, { isAdmin: boolean; expiresAt: number }> {
return webAdminStatusCache;
}

/**
* Invalidate Slack admin status cache. Pass no argument to clear the
* entire cache (used when admin assignments change for an unknown set of
* users).
* Former Slack cache invalidator retained for source compatibility.
*/
export function invalidateSlackAdminStatusCache(slackUserId?: string): void {
if (slackUserId) {
slackAdminStatusCache.delete(slackUserId);
} else {
slackAdminStatusCache.clear();
}
void slackUserId;
}

/**
* Invalidate web (WorkOS) admin status cache. Pass no argument to clear
* the entire cache.
* Former web cache invalidator retained for source compatibility.
*/
export function invalidateWebAdminStatusCache(workosUserId?: string): void {
if (workosUserId) {
webAdminStatusCache.delete(workosUserId);
} else {
webAdminStatusCache.clear();
}
void workosUserId;
}

/**
* Clear both caches. Used when an admin role change invalidates Slack
* and web entries simultaneously.
* Former aggregate invalidator retained for source compatibility.
*/
export function invalidateAllAdminStatusCaches(): void {
slackAdminStatusCache.clear();
webAdminStatusCache.clear();
// Compatibility no-op: AAO-admin authorization results are not cached.
}
95 changes: 63 additions & 32 deletions server/src/addie/admin-status-lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,15 @@
* small and stop `admin-tools` from being a chokepoint that pulls
* heavy services into unrelated chains.
*
* The function body is the same membership-against-`aao-admin` check
* the original lived in admin-tools.ts; admin-tools.ts now re-exports
* from here so existing callers keep working.
* Platform authority is evaluated for the credential which authenticated.
* A failed lookup is retryable and distinct from a confirmed lack of authority.
*/

import { createLogger } from '../logger.js';
import { WorkingGroupDatabase } from '../db/working-group-db.js';
import { getWebAdminStatusCache } from './admin-status-cache.js';
import {
decideAAOAdminAccess,
isBreakGlassAdminEmail,
type AAOAdminAccessDecision,
type AAOAdminAccessMechanism,
} from '../auth/admin-access.js';
Expand All @@ -32,41 +31,54 @@ export {
const logger = createLogger('admin-status-lookup');

const AAO_ADMIN_WORKING_GROUP_SLUG = 'aao-admin';
// This is deliberately short: invalidation only reaches the instance that
// performed an admin grant/revoke. Other replicas must therefore re-check
// membership quickly enough for an emergency revocation to take effect.
export const AAO_ADMIN_POSITIVE_CACHE_TTL_MS = 60 * 1000;
const AAO_ADMIN_NEGATIVE_CACHE_TTL_MS = 5 * 60 * 1000;

const wgDb = new WorkingGroupDatabase();

export async function isWebUserAAOAdmin(workosUserId: string): Promise<boolean> {
const cache = getWebAdminStatusCache();
const cached = cache.get(workosUserId);
if (cached && cached.expiresAt > Date.now()) {
return cached.isAdmin;
/** A person principal as authenticated, before canonical identity routing. */
export interface AAOAdminPrincipal {
id: string;
authWorkosUserId?: string;
email?: string | null;
}

/** A failed authority lookup is retryable, and must never become a grant or a denial. */
export class AAOAdminLookupUnavailableError extends Error {
readonly code = 'admin_authorization_unavailable';
readonly statusCode = 503;

constructor(options?: ErrorOptions) {
super('Administrator authorization is temporarily unavailable. Please try again.', options);
this.name = 'AAOAdminLookupUnavailableError';
}
}

/** Typed membership lookup used by the authenticated-principal boundary. */
async function lookupWebUserAAOAdmin(workosUserId: string): Promise<boolean> {
try {
const adminGroup = await wgDb.getWorkingGroupBySlug(AAO_ADMIN_WORKING_GROUP_SLUG);
if (!adminGroup) {
logger.warn('AAO Admin working group not found');
// Cache the negative result for a shorter time so a missing
// admin group doesn't pin everyone to non-admin for 30 minutes.
cache.set(workosUserId, { isAdmin: false, expiresAt: Date.now() + AAO_ADMIN_NEGATIVE_CACHE_TTL_MS });
return false;
const adminGroupId = await wgDb.getWorkingGroupIdBySlug(AAO_ADMIN_WORKING_GROUP_SLUG);
if (!adminGroupId) {
throw new Error('Platform administrator authority group is missing');
}

const isAdmin = await wgDb.isMember(adminGroup.id, workosUserId);
cache.set(workosUserId, {
isAdmin,
expiresAt: Date.now() + (isAdmin ? AAO_ADMIN_POSITIVE_CACHE_TTL_MS : AAO_ADMIN_NEGATIVE_CACHE_TTL_MS),
});
const isAdmin = await wgDb.isMember(adminGroupId, workosUserId);
logger.debug({ workosUserId, isAdmin }, 'Checked web user admin status');
return isAdmin;
} catch (error) {
logger.error({ error, workosUserId }, 'Error checking if web user is admin');
return false;
logger.error({ error, workosUserId, code: 'admin_authorization_unavailable' }, 'Platform administrator authorization lookup unavailable');
throw new AAOAdminLookupUnavailableError({ cause: error });
}
}

/**
* Compatibility adapter for web callers migrated in the follow-up sweep.
* An unavailable lookup remains false here; new authorization code must use
* the principal-taking resolver to distinguish unavailable from forbidden.
*/
export async function isWebUserAAOAdmin(workosUserId: string): Promise<boolean> {
try {
return await lookupWebUserAAOAdmin(workosUserId);
} catch (error) {
if (error instanceof AAOAdminLookupUnavailableError) return false;
throw error;
}
}

Expand All @@ -76,8 +88,27 @@ export async function isWebUserAAOAdmin(workosUserId: string): Promise<boolean>
* only the environment-managed break-glass fallback.
*/
export async function resolveWebUserAAOAdminAccess(
workosUserId: string,
email: string | null | undefined,
principal: AAOAdminPrincipal | string,
email?: string | null,
): Promise<AAOAdminAccessDecision> {
return decideAAOAdminAccess(await isWebUserAAOAdmin(workosUserId), email);
// Preserve the existing string overload until its web callers migrate.
if (typeof principal === 'string') {
return decideAAOAdminAccess(await isWebUserAAOAdmin(principal), email);
}
const workosUserId = principal.authWorkosUserId ?? principal.id;
const authenticatedEmail = principal.email;
try {
return decideAAOAdminAccess(await lookupWebUserAAOAdmin(workosUserId), authenticatedEmail);
} catch (error) {
// Break-glass is independently configured authority, including during an
// outage. It never comes from a canonical linked credential's email.
if (error instanceof AAOAdminLookupUnavailableError && isBreakGlassAdminEmail(authenticatedEmail)) {
return { isAdmin: true, mechanism: 'break_glass_admin_email' };
}
throw error;
}
}

export async function isAuthenticatedUserAAOAdmin(principal: AAOAdminPrincipal): Promise<boolean> {
return (await resolveWebUserAAOAdminAccess(principal)).isAdmin;
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@
"server/src/addie/prompt-assembly.ts": "6c002ae5a52cdd26e13f424f6e00757dff163b3b273a1b4ab069fdb86f4f375f",
"server/src/addie/register-baseline-tools.ts": "52893917fa19fa8904e1667857a7c4bfc2dc90bd3341e60d1ff45a40400d17aa",
"server/src/addie/bolt-app.ts": "cb1b76808654a295064cbd8b7dc580e17b9c4a54f80e882543cff5c15086632f",
"server/src/routes/addie-chat.ts": "be73414bc6a82ef43657adfa82f18f1646887023dd7c3af21cfcb4a4ff7dbd3b",
"server/src/routes/tavus.ts": "17321c275f89de1ec3e4996c6d7271bf252f7ff1ab1494dad820b090065d0da4",
"server/src/routes/addie-chat.ts": "0f900263ca9a4e8bae25a6942286cb12a0ba539ca9de63632ddc67d3ca1bde4b",
"server/src/routes/tavus.ts": "34d86376f5a01a6710fa385ab15181f946e81f5f9baecedc588e84756cecffd1",
"server/src/addie/email-conversation-handler.ts": "d6cf6d3c8621a85c69c6b22b86e6aa78f03b0d532ac8d5162fd40ec6c869f70c",
"server/src/mcp/chat-tool.ts": "a6bbde38fda11337a899fc92b8cabc3617377ae7c648609f34fae098f138f87e",
"server/src/addie/jobs/shadow-replay-cohort.ts": "316d0764cd65e4c631d6efef7d090fd46a6a8fff22d4c917a40b44a7a69eee1f",
Expand Down
11 changes: 7 additions & 4 deletions server/src/addie/home/web-home-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import type { HomeContent, GreetingSection } from './types.js';
import { getWebMemberContext, type MemberContext } from '../member-context.js';
import { isWebUserAAOAdmin } from '../mcp/admin-tools.js';
import { isAuthenticatedUserAAOAdmin, type AAOAdminPrincipal } from '../admin-status-lookup.js';
import { buildAlerts } from './builders/alerts.js';
import { buildQuickActions } from './builders/quick-actions.js';
import { buildActivityFeed } from './builders/activity.js';
Expand All @@ -22,14 +22,17 @@ const logger = createLogger('addie-web-home-service');
/**
* Get home content for a web user (WorkOS user ID)
*/
export async function getWebHomeContent(workosUserId: string, selectedOrganizationId?: string | null): Promise<HomeContent> {
export async function getWebHomeContent(user: AAOAdminPrincipal | string, selectedOrganizationId?: string | null): Promise<HomeContent> {
// Legacy internal previews pass an explicitly selected WorkOS credential.
const principal = typeof user === 'string' ? { id: user } : user;
const workosUserId = principal.id;
logger.debug({ workosUserId, selectedOrganizationId }, 'Addie Web Home: Building content');

// Get member context for web user
const memberContext = await getWebMemberContext(workosUserId, selectedOrganizationId);
const memberContext = await getWebMemberContext(workosUserId, selectedOrganizationId, principal);

// Check if user is AAO admin (based on aao-admin working group membership)
const userIsAdmin = await isWebUserAAOAdmin(workosUserId);
const userIsAdmin = await isAuthenticatedUserAAOAdmin(principal);

// Get admin user ID for prospect stats (if admin)
const adminUserId = userIsAdmin ? workosUserId : undefined;
Expand Down
Loading
Loading