authentication authorization
This commit is contained in:
91
apps/api/src/domain/policy/FeatureAvailabilityGuard.ts
Normal file
91
apps/api/src/domain/policy/FeatureAvailabilityGuard.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { PolicyService, ActionType, FeatureState } from './PolicyService';
|
||||
import { FEATURE_AVAILABILITY_METADATA_KEY, FeatureAvailabilityMetadata } from './RequireCapability';
|
||||
|
||||
type Evaluation = { allow: true } | { allow: false; reason: 'maintenance' | FeatureState | 'not_configured' };
|
||||
|
||||
@Injectable()
|
||||
export class FeatureAvailabilityGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly policyService: PolicyService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const handler = context.getHandler();
|
||||
const controllerClass = context.getClass();
|
||||
|
||||
const metadata =
|
||||
this.reflector.getAllAndOverride<FeatureAvailabilityMetadata | undefined>(
|
||||
FEATURE_AVAILABILITY_METADATA_KEY,
|
||||
[handler, controllerClass],
|
||||
) ?? null;
|
||||
|
||||
if (!metadata) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const snapshot = await this.policyService.getSnapshot();
|
||||
const decision = evaluate(snapshot, metadata);
|
||||
|
||||
if (decision.allow) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (decision.reason === 'maintenance') {
|
||||
throw new ServiceUnavailableException('Service temporarily unavailable');
|
||||
}
|
||||
|
||||
throw new NotFoundException('Not Found');
|
||||
}
|
||||
}
|
||||
|
||||
function evaluate(
|
||||
snapshot: Awaited<ReturnType<PolicyService['getSnapshot']>>,
|
||||
metadata: FeatureAvailabilityMetadata,
|
||||
): Evaluation {
|
||||
if (snapshot.operationalMode === 'maintenance') {
|
||||
const allowlist = metadata.actionType === 'mutate'
|
||||
? snapshot.maintenanceAllowlist.mutate
|
||||
: snapshot.maintenanceAllowlist.view;
|
||||
|
||||
if (!allowlist.includes(metadata.capabilityKey)) {
|
||||
return { allow: false, reason: 'maintenance' };
|
||||
}
|
||||
}
|
||||
|
||||
const state = snapshot.capabilities[metadata.capabilityKey] ?? 'hidden';
|
||||
|
||||
if (state === 'enabled') {
|
||||
return { allow: true };
|
||||
}
|
||||
|
||||
// Coming soon is treated as "not found" on the public API for now (no disclosure).
|
||||
if (state === 'coming_soon') {
|
||||
return { allow: false, reason: 'coming_soon' };
|
||||
}
|
||||
|
||||
if (state === 'disabled' || state === 'hidden') {
|
||||
return { allow: false, reason: state };
|
||||
}
|
||||
|
||||
return { allow: false, reason: 'not_configured' };
|
||||
}
|
||||
|
||||
export function inferActionTypeFromHttpMethod(method: string): ActionType {
|
||||
switch (method.toUpperCase()) {
|
||||
case 'GET':
|
||||
case 'HEAD':
|
||||
case 'OPTIONS':
|
||||
return 'view';
|
||||
default:
|
||||
return 'mutate';
|
||||
}
|
||||
}
|
||||
18
apps/api/src/domain/policy/PolicyController.ts
Normal file
18
apps/api/src/domain/policy/PolicyController.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Public } from '../auth/Public';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { PolicyService, PolicySnapshot } from './PolicyService';
|
||||
|
||||
@ApiTags('policy')
|
||||
@Public()
|
||||
@Controller('policy')
|
||||
export class PolicyController {
|
||||
constructor(private readonly policyService: PolicyService) {}
|
||||
|
||||
@Get('snapshot')
|
||||
@ApiOperation({ summary: 'Get current feature availability policy snapshot (read-only)' })
|
||||
@ApiResponse({ status: 200, description: 'Policy snapshot', type: Object })
|
||||
async getSnapshot(): Promise<PolicySnapshot> {
|
||||
return await this.policyService.getSnapshot();
|
||||
}
|
||||
}
|
||||
11
apps/api/src/domain/policy/PolicyModule.ts
Normal file
11
apps/api/src/domain/policy/PolicyModule.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PolicyController } from './PolicyController';
|
||||
import { PolicyService } from './PolicyService';
|
||||
import { FeatureAvailabilityGuard } from './FeatureAvailabilityGuard';
|
||||
|
||||
@Module({
|
||||
controllers: [PolicyController],
|
||||
providers: [PolicyService, FeatureAvailabilityGuard],
|
||||
exports: [PolicyService, FeatureAvailabilityGuard],
|
||||
})
|
||||
export class PolicyModule {}
|
||||
229
apps/api/src/domain/policy/PolicyService.ts
Normal file
229
apps/api/src/domain/policy/PolicyService.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
export type OperationalMode = 'normal' | 'maintenance' | 'test';
|
||||
export type FeatureState = 'enabled' | 'disabled' | 'coming_soon' | 'hidden';
|
||||
export type ActionType = 'view' | 'mutate';
|
||||
|
||||
export type PolicySnapshot = {
|
||||
readonly policyVersion: number;
|
||||
readonly operationalMode: OperationalMode;
|
||||
readonly maintenanceAllowlist: {
|
||||
readonly view: readonly string[];
|
||||
readonly mutate: readonly string[];
|
||||
};
|
||||
readonly capabilities: Readonly<Record<string, FeatureState>>;
|
||||
readonly loadedFrom: 'env' | 'file' | 'defaults';
|
||||
readonly loadedAtIso: string;
|
||||
};
|
||||
|
||||
const DEFAULT_POLICY_VERSION = 1;
|
||||
const DEFAULT_CACHE_MS = 5_000;
|
||||
|
||||
type RawPolicySnapshot = Partial<{
|
||||
policyVersion: number;
|
||||
operationalMode: string;
|
||||
maintenanceAllowlist: Partial<{
|
||||
view: unknown;
|
||||
mutate: unknown;
|
||||
}>;
|
||||
capabilities: unknown;
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class PolicyService {
|
||||
private cache:
|
||||
| {
|
||||
snapshot: PolicySnapshot;
|
||||
expiresAtMs: number;
|
||||
}
|
||||
| null = null;
|
||||
|
||||
async getSnapshot(): Promise<PolicySnapshot> {
|
||||
const now = Date.now();
|
||||
if (this.cache && now < this.cache.expiresAtMs) {
|
||||
return this.cache.snapshot;
|
||||
}
|
||||
|
||||
const cacheMs = parseCacheMs(process.env.GRIDPILOT_POLICY_CACHE_MS);
|
||||
const loadedAtIso = new Date(now).toISOString();
|
||||
const { raw, loadedFrom } = await this.loadRawSnapshot();
|
||||
const snapshot = normalizeSnapshot(raw, loadedFrom, loadedAtIso);
|
||||
|
||||
this.cache = {
|
||||
snapshot,
|
||||
expiresAtMs: now + cacheMs,
|
||||
};
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private async loadRawSnapshot(): Promise<{
|
||||
raw: RawPolicySnapshot;
|
||||
loadedFrom: PolicySnapshot['loadedFrom'];
|
||||
}> {
|
||||
const policyPath = process.env.GRIDPILOT_POLICY_PATH;
|
||||
|
||||
if (policyPath) {
|
||||
const rawJson = await readFile(policyPath, 'utf8');
|
||||
return {
|
||||
raw: JSON.parse(rawJson) as RawPolicySnapshot,
|
||||
loadedFrom: 'file',
|
||||
};
|
||||
}
|
||||
|
||||
const anyEnvConfigured =
|
||||
Boolean(process.env.GRIDPILOT_OPERATIONAL_MODE) ||
|
||||
Boolean(process.env.GRIDPILOT_FEATURES_JSON) ||
|
||||
Boolean(process.env.GRIDPILOT_MAINTENANCE_ALLOW_VIEW) ||
|
||||
Boolean(process.env.GRIDPILOT_MAINTENANCE_ALLOW_MUTATE);
|
||||
|
||||
const raw: RawPolicySnapshot = {};
|
||||
|
||||
const operationalMode = process.env.GRIDPILOT_OPERATIONAL_MODE;
|
||||
if (operationalMode) {
|
||||
raw.operationalMode = operationalMode;
|
||||
}
|
||||
|
||||
const capabilities = parseFeaturesJson(process.env.GRIDPILOT_FEATURES_JSON);
|
||||
if (capabilities) {
|
||||
raw.capabilities = capabilities;
|
||||
}
|
||||
|
||||
const maintenanceAllowView = parseCsvList(process.env.GRIDPILOT_MAINTENANCE_ALLOW_VIEW);
|
||||
const maintenanceAllowMutate = parseCsvList(process.env.GRIDPILOT_MAINTENANCE_ALLOW_MUTATE);
|
||||
|
||||
if (maintenanceAllowView || maintenanceAllowMutate) {
|
||||
raw.maintenanceAllowlist = {
|
||||
...(maintenanceAllowView ? { view: maintenanceAllowView } : {}),
|
||||
...(maintenanceAllowMutate ? { mutate: maintenanceAllowMutate } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
raw,
|
||||
loadedFrom: anyEnvConfigured ? 'env' : 'defaults',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSnapshot(
|
||||
raw: RawPolicySnapshot,
|
||||
loadedFrom: PolicySnapshot['loadedFrom'],
|
||||
loadedAtIso: string,
|
||||
): PolicySnapshot {
|
||||
const operationalMode = parseOperationalMode(raw.operationalMode);
|
||||
|
||||
const maintenanceAllowlistView = normalizeStringArray(raw.maintenanceAllowlist?.view);
|
||||
const maintenanceAllowlistMutate = normalizeStringArray(raw.maintenanceAllowlist?.mutate);
|
||||
|
||||
const capabilities = normalizeCapabilities(raw.capabilities);
|
||||
|
||||
return {
|
||||
policyVersion:
|
||||
typeof raw.policyVersion === 'number' ? raw.policyVersion : DEFAULT_POLICY_VERSION,
|
||||
operationalMode,
|
||||
maintenanceAllowlist: {
|
||||
view: maintenanceAllowlistView,
|
||||
mutate: maintenanceAllowlistMutate,
|
||||
},
|
||||
capabilities,
|
||||
loadedFrom,
|
||||
loadedAtIso,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCacheMs(cacheMsRaw: string | undefined): number {
|
||||
if (!cacheMsRaw) {
|
||||
return DEFAULT_CACHE_MS;
|
||||
}
|
||||
const parsed = Number(cacheMsRaw);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return DEFAULT_CACHE_MS;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseOperationalMode(raw: string | undefined): OperationalMode {
|
||||
switch (raw) {
|
||||
case 'normal':
|
||||
case 'maintenance':
|
||||
case 'test':
|
||||
return raw;
|
||||
default:
|
||||
return 'normal';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCapabilities(input: unknown): Readonly<Record<string, FeatureState>> {
|
||||
if (!input || typeof input !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const record = input as Record<string, unknown>;
|
||||
const normalized: Record<string, FeatureState> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const state = parseFeatureState(value);
|
||||
if (state) {
|
||||
normalized[key] = state;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseFeatureState(value: unknown): FeatureState | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
switch (value) {
|
||||
case 'enabled':
|
||||
case 'disabled':
|
||||
case 'coming_soon':
|
||||
case 'hidden':
|
||||
return value;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseFeaturesJson(raw: string | undefined): unknown {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCsvList(raw: string | undefined): readonly string[] | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const items = raw
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): readonly string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.filter((v) => typeof v === 'string')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
24
apps/api/src/domain/policy/RequireCapability.ts
Normal file
24
apps/api/src/domain/policy/RequireCapability.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import type { ActionType } from './PolicyService';
|
||||
|
||||
export const FEATURE_AVAILABILITY_METADATA_KEY = 'gridpilot:featureAvailability';
|
||||
|
||||
export type FeatureAvailabilityMetadata = {
|
||||
readonly capabilityKey: string;
|
||||
readonly actionType: ActionType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attach feature availability metadata to a controller or route handler.
|
||||
*
|
||||
* The backend Guard is authoritative enforcement (not UX).
|
||||
*/
|
||||
export function RequireCapability(
|
||||
capabilityKey: string,
|
||||
actionType: ActionType,
|
||||
): MethodDecorator & ClassDecorator {
|
||||
return SetMetadata(FEATURE_AVAILABILITY_METADATA_KEY, {
|
||||
capabilityKey,
|
||||
actionType,
|
||||
} satisfies FeatureAvailabilityMetadata);
|
||||
}
|
||||
Reference in New Issue
Block a user