@accounter/server
Version:
Accounter GraphQL server
112 lines (100 loc) • 4.26 kB
text/typescript
import { GraphQLError } from 'graphql';
import { Injectable, Scope } from 'graphql-modules';
import { narrowReadScope, readScopeFromMemberships } from '../../../shared/helpers/auth-scope.js';
import type { AuthContext } from '../../../shared/types/auth.js';
import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js';
import type { AdminContext } from '../../admin-context/types.js';
import { AuthContextProvider } from './auth-context.provider.js';
/**
* Single, reusable entry point for request-level business scope decisions.
*
* Resolvers and providers should use this rather than re-implementing scope
* narrowing or reaching into the admin context per module:
* - reads default to all accessible businesses, narrowed by request args;
* - writes require an explicit, membership-validated single target;
* - per-business preferences are resolved within the authorized read scope.
*/
({
scope: Scope.Operation,
global: true,
})
export class ScopeProvider {
constructor(
private authContextProvider: AuthContextProvider,
private adminContextProvider: AdminContextProvider,
) {}
private async requireAuthContext(): Promise<AuthContext> {
const authContext = await this.authContextProvider.getAuthContext();
if (!authContext) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return authContext;
}
/**
* The effective read scope: the request's authorized scope
* (header ∩ memberships, resolved by the auth context) narrowed by the
* optionally-provided args business ids. Defaults to all accessible
* businesses. Throws when args request a business outside the authorized
* scope (callers must reject, never broaden).
*/
public async getReadScope(argsBusinessIds?: string[]): Promise<string[]> {
const authContext = await this.requireAuthContext();
const baseScope =
authContext.activeReadScope ?? readScopeFromMemberships(authContext.memberships ?? []);
if (!argsBusinessIds || argsBusinessIds.length === 0) {
return baseScope.businessIds;
}
const allowed = baseScope.businessIds.map(businessId => ({ businessId, roleId: '' }));
const narrowed = narrowReadScope(allowed, argsBusinessIds);
if (!narrowed) {
throw new GraphQLError('Requested business scope is outside the authorized read scope', {
extensions: { code: 'FORBIDDEN' },
});
}
return narrowed.businessIds;
}
/**
* Validate and return the single write-target business. Writes always take an
* explicit business id (never inferred from the read scope); the target must
* be one of the user's memberships.
*/
public async resolveWriteTarget(requestedBusinessId: string | null | undefined): Promise<string> {
const authContext = await this.requireAuthContext();
if (!requestedBusinessId) {
throw new GraphQLError('An explicit target businessId is required for writes', {
extensions: { code: 'BAD_USER_INPUT' },
});
}
const isMember = (authContext.memberships ?? []).some(
membership => membership.businessId === requestedBusinessId,
);
if (!isMember) {
throw new GraphQLError('Not authorized to write to the requested business', {
extensions: { code: 'FORBIDDEN' },
});
}
return requestedBusinessId;
}
/**
* Resolve a single business preference (e.g. `defaultLocalCurrency`) for a
* specific business within the authorized read scope. Replaces per-field
* reads of the request's single admin context, so multi-business reads can
* resolve preferences from each row's owning business.
*/
public async getBusinessPreference<K extends keyof AdminContext>(
businessId: string,
key: K,
): Promise<AdminContext[K] | null> {
const scope = await this.getReadScope();
if (!scope.includes(businessId)) {
throw new GraphQLError('Business is outside the authorized read scope', {
extensions: { code: 'FORBIDDEN' },
});
}
const adminContext =
await this.adminContextProvider.adminContextByOwnerIdLoader.load(businessId);
return adminContext ? adminContext[key] : null;
}
}