From 8b45d2f1a6620e2e297d01756050e5527b37c784 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 7 Sep 2026 21:40:53 +0200 Subject: [PATCH] feat(domain): controle d acces aux capacites par role et par offre Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C --- .../domain/services/capability-access.spec.ts | 98 +++++++++++++++++ .../src/domain/services/capability-access.ts | 101 ++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 apps/backend/src/domain/services/capability-access.spec.ts create mode 100644 apps/backend/src/domain/services/capability-access.ts diff --git a/apps/backend/src/domain/services/capability-access.spec.ts b/apps/backend/src/domain/services/capability-access.spec.ts new file mode 100644 index 0000000..fe75f17 --- /dev/null +++ b/apps/backend/src/domain/services/capability-access.spec.ts @@ -0,0 +1,98 @@ +import { + CapabilityActor, + CapabilityPolicy, + actorPlan, + canInvoke, + denialReason, + grantedCapabilities, +} from './capability-access'; + +const actor = (overrides: Partial = {}): CapabilityActor => ({ + id: 'u1', + organizationId: 'o1', + role: 'USER', + plan: 'BRONZE', + ...overrides, +}); + +describe('actorPlan', () => { + it('uses the organisation plan for an ordinary account', () => { + expect(actorPlan(actor({ plan: 'SILVER' }))).toBe('SILVER'); + }); + + it('gives an ADMIN the Platinium plan, as the rest of the product does', () => { + expect(actorPlan(actor({ role: 'ADMIN', plan: 'BRONZE' }))).toBe('PLATINIUM'); + }); + + it('maps legacy plan names', () => { + expect(actorPlan(actor({ plan: 'PRO' }))).toBe('GOLD'); + }); + + it.each([undefined, '', 'LEGACY_TIER'])('falls back to Bronze for plan %p', plan => { + expect(actorPlan(actor({ plan }))).toBe('BRONZE'); + }); +}); + +describe('canInvoke', () => { + const openToAll: CapabilityPolicy = { name: 'whoami', scope: 'read' }; + const managersOnly: CapabilityPolicy = { + name: 'list_organization_bookings', + scope: 'read', + roles: ['ADMIN', 'MANAGER'], + }; + const needsApi: CapabilityPolicy = { name: 'export', scope: 'read', feature: 'api_access' }; + + it('lets any authenticated account use an unrestricted capability', () => { + expect(canInvoke(actor(), openToAll)).toBe(true); + }); + + it('restricts by role, case-insensitively like the roles guard', () => { + expect(canInvoke(actor({ role: 'USER' }), managersOnly)).toBe(false); + expect(canInvoke(actor({ role: 'MANAGER' }), managersOnly)).toBe(true); + expect(canInvoke(actor({ role: 'manager' }), managersOnly)).toBe(true); + }); + + it('restricts by subscription feature', () => { + // `api_access` n'est ouvert qu'a Gold et Platinium. + expect(canInvoke(actor({ plan: 'SILVER' }), needsApi)).toBe(false); + expect(canInvoke(actor({ plan: 'GOLD' }), needsApi)).toBe(true); + }); + + it('opens plan-gated capabilities to an ADMIN whatever the organisation pays', () => { + expect(canInvoke(actor({ role: 'ADMIN', plan: 'BRONZE' }), needsApi)).toBe(true); + }); + + it('still refuses a role-gated capability to an ADMIN excluded from it', () => { + // Le role prime : l'offre Platinium n'accorde pas un role. + const carrierOnly: CapabilityPolicy = { name: 'x', scope: 'read', roles: ['CARRIER'] }; + expect(canInvoke(actor({ role: 'ADMIN' }), carrierOnly)).toBe(false); + }); +}); + +describe('grantedCapabilities', () => { + const catalogue = [ + { policy: { name: 'whoami', scope: 'read' } as CapabilityPolicy }, + { policy: { name: 'org', scope: 'read', roles: ['ADMIN'] } as CapabilityPolicy }, + { policy: { name: 'api', scope: 'read', feature: 'api_access' } as CapabilityPolicy }, + ]; + + it('hides what the caller may not invoke, rather than listing it as refused', () => { + const names = grantedCapabilities(actor({ role: 'USER', plan: 'BRONZE' }), catalogue).map( + c => c.policy.name + ); + expect(names).toEqual(['whoami']); + }); + + it('shows everything to an ADMIN', () => { + const names = grantedCapabilities(actor({ role: 'ADMIN' }), catalogue).map(c => c.policy.name); + expect(names).toEqual(['whoami', 'org', 'api']); + }); +}); + +describe('denialReason', () => { + it('separates a missing role from a missing plan feature', () => { + expect(denialReason(actor(), { name: 'x', scope: 'read' })).toBeNull(); + expect(denialReason(actor(), { name: 'x', scope: 'read', roles: ['ADMIN'] })).toBe('role'); + expect(denialReason(actor(), { name: 'x', scope: 'read', feature: 'api_access' })).toBe('plan'); + }); +}); diff --git a/apps/backend/src/domain/services/capability-access.ts b/apps/backend/src/domain/services/capability-access.ts new file mode 100644 index 0000000..f409380 --- /dev/null +++ b/apps/backend/src/domain/services/capability-access.ts @@ -0,0 +1,101 @@ +import { PlanFeature, planHasFeature } from '../value-objects/plan-feature.vo'; +import { SubscriptionPlan, SubscriptionPlanType } from '../value-objects/subscription-plan.vo'; +import { effectivePlan } from './subscription-access'; + +/** + * Politique d'acces aux capacites exposees par l'assistant et par le serveur MCP. + * + * Une capacite est une action du produit rendue appelable par un agent. Elle + * n'est pas decrite dans un prompt : elle est declaree ici avec ce qu'elle + * exige, et le controle a lieu dans le processus, sur l'identite authentifiee. + * Un modele peut se tromper de mot, il ne peut pas se donner un role. + * + * Deux conditions, verifiees dans cet ordre : + * + * 1. **Le role** — qui a le droit d'agir (`ADMIN`, `MANAGER`, `USER`...). + * 2. **L'offre** — ce que l'abonnement de l'organisation ouvre, via les memes + * `PLAN_FEATURES` que le reste du produit. + * + * L'offre effective passe par `effectivePlan` : un compte ADMIN dispose de + * Platinium, exactement comme dans l'apercu d'abonnement et dans l'assistant. + */ + +/** `read` n'ecrit rien ; `write` modifie l'etat du produit. */ +export type CapabilityScope = 'read' | 'write'; + +export interface CapabilityPolicy { + /** Identifiant stable, expose tel quel aux clients MCP. */ + name: string; + scope: CapabilityScope; + /** Roles autorises. Absent : tout compte authentifie. */ + roles?: readonly string[]; + /** Fonctionnalite d'offre requise. Absent : aucune condition d'abonnement. */ + feature?: PlanFeature; +} + +export interface CapabilityActor { + id: string; + organizationId: string; + role?: string; + /** Adresse de l'appelant, reportee telle quelle dans le journal d'audit. */ + email?: string; + /** Offre de l'organisation. Inconnue ou absente : Bronze. */ + plan?: string; +} + +/** + * Offre effective de l'appelant. + * + * Une valeur inconnue retombe sur Bronze, l'offre la plus restrictive, plutot + * que de faire echouer l'appel ou — pire — de l'autoriser par defaut. + */ +export function actorPlan(actor: CapabilityActor): SubscriptionPlanType { + let declared: SubscriptionPlan | null = null; + try { + if (actor.plan) declared = SubscriptionPlan.fromString(actor.plan); + } catch { + declared = null; + } + return effectivePlan(actor.role, declared).value; +} + +export function canInvoke(actor: CapabilityActor, policy: CapabilityPolicy): boolean { + if ( + policy.roles && + !policy.roles.some(role => role.toLowerCase() === actor.role?.toLowerCase()) + ) { + return false; + } + + if (policy.feature && !planHasFeature(actorPlan(actor), policy.feature)) { + return false; + } + + return true; +} + +/** + * Filtre un catalogue pour un appelant. + * + * Une capacite hors de ses droits n'est pas seulement refusee a l'appel : elle + * n'apparait pas dans la liste. Un agent ne peut pas proposer, ni meme + * mentionner, une action que la personne n'a pas le droit de declencher. + */ +export function grantedCapabilities( + actor: CapabilityActor, + capabilities: readonly T[] +): T[] { + return capabilities.filter(capability => canInvoke(actor, capability.policy)); +} + +/** Raison du refus, destinee au message d'erreur rendu a l'agent. */ +export function denialReason( + actor: CapabilityActor, + policy: CapabilityPolicy +): 'role' | 'plan' | null { + if (canInvoke(actor, policy)) return null; + if (policy.roles && !policy.roles.some(r => r.toLowerCase() === actor.role?.toLowerCase())) { + return 'role'; + } + return 'plan'; +}