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:
parent
b3564a19f6
commit
8b45d2f1a6
98
apps/backend/src/domain/services/capability-access.spec.ts
Normal file
98
apps/backend/src/domain/services/capability-access.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
101
apps/backend/src/domain/services/capability-access.ts
Normal file
101
apps/backend/src/domain/services/capability-access.ts
Normal 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';
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user