authentication authorization

This commit is contained in:
2025-12-26 15:32:22 +01:00
parent 68ae9da22a
commit 64377de548
54 changed files with 2833 additions and 95 deletions

View 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';
}
}

View 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();
}
}

View 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 {}

View 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;
}

View 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);
}