@accounter/server
Version:
Accounter GraphQL server
151 lines (137 loc) • 5.33 kB
text/typescript
import type { AuthorizedReadScope, BusinessMembership, TenantContext } from '../types/auth.js';
/**
* Resolve the single write-target / owning business for a request given the
* primary tenant business and the active read scope.
*
* Precedence:
* - No scope → the primary tenant business.
* - Single scoped business → that business.
* - Multiple scoped businesses, primary among them → the primary (stable).
* - Multiple scoped businesses, primary NOT among them → the first scoped
* business (writing to the primary would violate tenant isolation since it
* is outside the active scope).
*
* This is the single source of truth for "which business does this request own
* / write to"; the auth context re-points `tenant` with it and DB consumers
* reuse it rather than re-deriving the rule inline.
*/
export function resolveWriteTargetBusinessId(
primaryBusinessId: string | null | undefined,
activeReadScope: AuthorizedReadScope | null | undefined,
): string | null {
const primary = primaryBusinessId ?? null;
const scopedIds = activeReadScope?.businessIds;
if (!scopedIds || scopedIds.length === 0) {
return primary;
}
if (scopedIds.length === 1) {
return scopedIds[0];
}
return primary && scopedIds.includes(primary) ? primary : scopedIds[0];
}
/**
* Adapter helpers bridging the legacy single-business `TenantContext` and the
* multi-business membership / read-scope types. These let existing code keep
* compiling while the migration introduces membership-aware auth, before any
* provider/resolver is switched over.
*/
/** Build a membership from the legacy single-business tenant context. */
export function membershipFromTenant(tenant: TenantContext): BusinessMembership {
return {
businessId: tenant.businessId,
roleId: tenant.roleId ?? '',
...(tenant.businessName ? { businessName: tenant.businessName } : {}),
};
}
/** Collapse a membership back into the legacy single-business tenant context. */
export function tenantFromMembership(membership: BusinessMembership): TenantContext {
return {
businessId: membership.businessId,
roleId: membership.roleId,
...(membership.businessName ? { businessName: membership.businessName } : {}),
};
}
/**
* Default read scope = every business the user belongs to, de-duplicated and
* order-preserving.
*/
export function readScopeFromMemberships(
memberships: BusinessMembership[] | undefined | null,
): AuthorizedReadScope {
const businessIds = memberships ? [...new Set(memberships.map(m => m.businessId))] : [];
return { businessIds };
}
/** Whether a business id is part of an authorized read scope. */
export function isBusinessInScope(
scope: AuthorizedReadScope | undefined | null,
businessId: string,
): boolean {
return scope?.businessIds.includes(businessId) ?? false;
}
/**
* Resolve the effective read scope for a request by applying the precedence
* rule: GraphQL args narrow the header scope, which narrows the user's
* memberships. Formally `args ⊆ header ⊆ memberships`.
*
* - When neither header nor args narrowing is requested, defaults to all
* accessible businesses.
* - The header scope must be a subset of the memberships; the args scope must
* be a subset of the (already header-narrowed) scope.
* - Returns `null` to signal rejection when any requested id falls outside the
* scope it is narrowing — callers must reject rather than silently drop ids.
*
* This is the single, reusable precedence check; resolvers and the scope
* provider should use it rather than re-implementing narrowing per module.
*/
export function resolveReadScopePrecedence(params: {
memberships: BusinessMembership[];
headerBusinessIds?: string[];
argsBusinessIds?: string[];
}): AuthorizedReadScope | null {
const { memberships, headerBusinessIds, argsBusinessIds } = params;
let scope = readScopeFromMemberships(memberships);
if (headerBusinessIds && headerBusinessIds.length > 0) {
const narrowed = narrowReadScope(memberships, headerBusinessIds);
if (!narrowed) {
return null;
}
scope = narrowed;
}
if (argsBusinessIds && argsBusinessIds.length > 0) {
const allowed: BusinessMembership[] = scope.businessIds.map(businessId => ({
businessId,
roleId: '',
}));
const narrowed = narrowReadScope(allowed, argsBusinessIds);
if (!narrowed) {
return null;
}
scope = narrowed;
}
return scope;
}
/**
* Narrow a user's memberships to a requested set of business ids.
*
* Returns the requested ids (de-duplicated, request order preserved) as the
* read scope, or `null` if ANY requested id is outside the user's memberships —
* callers must reject such requests rather than silently dropping unknown ids.
*/
export function narrowReadScope(
memberships: BusinessMembership[],
requestedBusinessIds: string[],
): AuthorizedReadScope | null {
const allowed = new Set(memberships.map(membership => membership.businessId));
const seen = new Set<string>();
const businessIds: string[] = [];
for (const businessId of requestedBusinessIds) {
if (!allowed.has(businessId)) {
return null;
}
if (!seen.has(businessId)) {
seen.add(businessId);
businessIds.push(businessId);
}
}
return { businessIds };
}