feat(domain): controle d acces aux capacites par role et par offre

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
This commit is contained in:
David 2026-09-07 21:40:53 +02:00
parent b3564a19f6
commit 8b45d2f1a6
2 changed files with 199 additions and 0 deletions

View File

@ -0,0 +1,98 @@
import {
CapabilityActor,
CapabilityPolicy,
actorPlan,
canInvoke,
denialReason,
grantedCapabilities,
} from './capability-access';
const actor = (overrides: Partial<CapabilityActor> = {}): 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');
});
});

View File

@ -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<T extends { policy: CapabilityPolicy }>(
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';
}