Access Control
Security Reference Material
- Security requirements attachment: DEUSS - SecurityAndDataProtection_REQ.xlsx
- Cybersecurity requirements backlog: Google Sheets backlog
Access Control & Resource Authorization
This document defines the business requirements, specific authorization constraints, and the agreed technical design for access control within the DEUSS Core microservices.
1. Business Requirements
- Business Context Propagation: Every request originating from an external system (Broker Platform app, PSP app, etc.) must arrive at the target service (e.g., Registry Service) containing a clear business context. This context consists of:
partner_id: A UUID from thepartnerstable in the Partner Service. It represents the partner entity calling the DEUSS Core services. Target services use this ID to ensure the external application can only manipulate entities it "officially" manages (Tenant Isolation).partner_role: Specifies the type of partner—one ofBROKER,PSP, orGUARANTOR.
- Immediate Isolation of Suspended Partners: If an administrator blocks an external system via the Backoffice (Partner Service), this event must be immediately propagated across the infrastructure. The result must be an instant technical denial of new API requests from this partner, as well as the halting of specific background processes currently in progress.
- Separated Access for Backoffice: The internal Backoffice application is a Client-Side Rendered (CSR) frontend. It does not call APIs as an "external system," but acts directly on behalf of the logged-in DEUSS administrator. The Backoffice will call specific, dedicated admin endpoints on the DEUSS Core services. Data isolation via
partner_iddoes not apply here (admins have global visibility); instead, access is strictly governed by verifying the user's administrative roles.
2. Specific Authorization Requirements
This section contains the complete list of use cases across all DEUSS Core services. Each use case describes which actors can take action and under which conditions.
Registry Service UCs
Payment Service UCs
TODO
Partner Service UCs
TODO
3. Technical Solution Design
To fulfill the business requirements efficiently, the architecture employs a Gateway Offloading pattern for external systems and a standard RBAC approach for internal administrators.
A. Authorization for TechUsers (External Applications)
- Entity Initialization: A DEUSS admin creates the partner entity in the Partner Service via the Backoffice. This establishes the
partner_idand thepartner_role(linking tobroker_details,psp_details, etc.). - Certificate Issuance: During the technical integration process, these two attributes (
partner_idandpartner_role) are embedded directly into the metadata of the mTLS certificate generated for the partner. - Request Interception (Istio): When the external application calls the DEUSS Core API, it authenticates using this certificate. The Istio Ingress Gateway intercepts the request, validates the certificate, extracts the embedded context, and injects
partner_idandpartner_roleas standard HTTP headers. - Service-Level Authorization: The target service receives the request with the headers already attached and performs fine-grained authorization:
- Endpoint Access: Evaluates the
partner_roleheader to decide if the caller is allowed to trigger the endpoint. - Tenant Isolation: Evaluates the
partner_idheader against its local database to verify if the caller has ownership rights over the specific entity being manipulated.
- Endpoint Access: Evaluates the
B. Authorization for EndUsers (DEUSS Backoffice Admins)
- Dedicated Admin Routes: Services will expose separate routing paths (e.g.,
/api/v1/admin/...) exclusively for human administrators. - RBAC Validation: These requests are authenticated using a standard JWT token generated by the Identity Provider upon frontend login. Access is controlled purely via Role-Based Access Control (RBAC) directly at the application controller level (e.g., verifying if the JWT contains the
GOVERNANCE_ADMINrole).
C. Propagation of Blocking Events
- API Access Revocation: If the Partner Service changes an external system's status to blocked, its API access is immediately revoked at the infrastructure level (e.g., via certificate invalidation/CRL or Istio blacklist).
- Background Process Halting: DEUSS Core services executing critical asynchronous processes must perform a final verification check before completion. The service will query the Partner Service (e.g., via a synchronous REST API call) to confirm the partner has not been blocked during the execution time, preventing the finalization of unauthorized operations.
Security and Data Protection Reference
Additional security, tenant isolation, privileged access management, compliance, and data protection requirements are described in the main architecture documentation section: 1.6 Security, Compliance, and Data Protection This includes:
- tenant isolation principles
- RBAC and privileged access control
- audit logging and monitoring
- data retention and compliance requirements
- GDPR and financial regulatory considerations
- security controls for sensitive and compliance-critical data.
End-user Context Propagation

Important
This subsection describes an x5c-based approach that was explicitly deferred. The current decision is to send only an unsigned JSON payload.
1. Structure of endUserContextJWT
The integrated partner application (Broker Platform) generates a signed JSON Web Token (JWT) for each API request containing the end-user context. The design uses a standard JWT with the x5c header for cryptographic verification without requiring network calls to a key database.
A. JWT Header
Contains cryptographic metadata and the partner's public certificate.
{
"alg": "RS256",
"typ": "JWT",
"x5c": [
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A..."
]
}
B. JWT Payload
Contains user identity, session context, and audit-trail data. It does not contain business roles; authorization is performed in DEUSS Core based on resource ownership.
{
"iss": "urn:deuss:partner:broker-abc",
"aud": "urn:deuss:core",
"iat": 1716460000,
"exp": 1716460300,
"jti": "550e8400-e29b-41d4-a716-446655440000",
"sub": "user-uuid-8899",
"partner_user_id": "keycloak-id-123",
"sid": "a1b2c3d4e5f6g7h8",
"app_id": "broker-abc-ios-app",
"client_ip": "198.51.100.14",
"user_agent": "Mozilla/5.0 (iPhone; CPU...)",
"amr": ["pwd", "mfa"],
"auth_time": 1716459000
}
2. Request Lifecycle (End-to-End)
Phase 1: Onboarding and PKI (One-time)
- The DEUSS Root CA (stored in OpenBao) is mounted locally in every DEUSS Core service container as the public certificate
root-ca.crt. - The partner submits a CSR, and DEUSS signs and issues a JWT signing certificate.
Phase 2: Sending from the Broker Platform Application
- The Broker Platform backend builds the JWT (payload + header with
x5c), signs it with its private key, and places it in the HTTP headerX-End-User-Context. - The request is sent to the DEUSS platform through a persistent pre-established mTLS tunnel.
Phase 3: Istio Gateway
- Istio intercepts the request. From the valid mTLS connection, Istio securely knows the partner identity and adds it as clean HTTP headers:
X-Partner-Id: broker-abcX-Partner-Role: BROKER
- Istio forwards the request, including the unchanged
X-End-User-Contextheader, into the internal network to the target DEUSS Core microservice.
Phase 4: Cryptographic Verification in DEUSS Core
- The request enters the service container and is intercepted by a central security filter.
- The filter extracts the partner certificate from
x5c[0]and verifies with the localroot-ca.crtthat the certificate was issued by the DEUSS platform and is still valid. - The filter extracts the public key from the partner certificate and uses it to verify the digital signature of the entire JWT.
- If everything is valid, the filter unwraps the JWT and passes the payload data into the application business logic.
Phase 5: Business Logic and Audit
- The service receives verified data: the tenant identity (
X-Partner-Id) and the end-user identity (sub). - The service performs ownership-based authorization by verifying in the database or Indexer that the user (
sub) is allowed to manipulate the requested resource. - The service executes the action and writes a structured audit-log entry to preserve traceability to the end user.
Backend-Owned Permission Model
744 — Backend-Owned Permission Model for Protected Resources
Parent issue for the file-domain enforcement work in #745 and the boundary work in #743. The Permission Model defined here is the single contract those issues consume.
Audited 2026-05-29 against current code. All structural findings hold:CompanyValidatoris still the only real instance-level check;Issuer/Originator/IssuanceDraftValidatorare existence-only;DraftDocumentFacadeis still IDOR-eligible; there is still no centralizedauthorize(subject, resource, action). One minor drift applied (OriginatorsControllerauthorization is now method-level). Line-number citations removed (they rot; anchor to class/method).
1. Context
The platform handles sensitive business resources — company profile, originator records, issuance drafts, and the attached private files (company documents, scoring documents, draft documents, marketing assets). Today, access decisions are primarily expressed on the frontend via permission hooks (useCompanyPermissions.ts, useOriginatorPermissions.ts, useBondPermissions.ts) and role-based route guards in App.tsx.
Frontend permission logic is the correct place for UX (hiding actions, navigation gating). It is not a security boundary — every check it makes is trivially bypassable by calling the backend directly.
2. Terminology (use these terms consistently)
| Term | Meaning |
|---|---|
| Subject | Authenticated principal making the request (user or service account). Identified by userId and the Roles set carried on the JWT (USER, ADMIN, ORIGINATOR). |
| Resource | A protected business entity instance — e.g. Company#123, IssuanceDraft#456. Always identified by (type, id), never by type alone. |
| Parent Resource | The owning entity at the root of the authorization chain (a DraftDocument's parent is its IssuanceDraft; an IssuanceDraft's parent for permission purposes is its owning Company; a Company may be managed by an Originator). |
| Action | A CRUD verb — create, read, update, delete. Lifecycle operations (approve, reject, send for verification) decompose into update on the parent Resource plus a state-machine guard; they are not first-class permission actions. |
| Permission Model | The single contract `authorize(subject, resource, action) → Allow |
| Policy Enforcement Point (PEP) | The backend layer that calls the Permission Model on every state-changing or data-returning operation. There must be exactly one PEP per controller path — facade-level checks, not a mix of "sometimes controller, sometimes service". |
| Subject-Resource Binding | The persistent graph that determines which Subjects have which roles on which Parent Resources. See §4.2. |
| IDOR | Insecure Direct Object Reference — the attacker swaps an ID in the request and the backend returns or mutates someone else's data because no instance-level check ran. The class of bug this issue eliminates. |
3. Current State (with code references)
3.1 Frontend (UX layer — correct in role, wrong in load-bearing weight)
- Permission hooks in
apps/deuss-frontend/src/hooks/:useCompanyPermissions.ts— inputs:company.employee_role,signedUser.roles,company.issuer_details,company.investor_details. ExposescanEditGeneralInfo,canEditDocuments,canEditEmployees,canSendForValidation,canDeleteCompany,canEditScoringDocuments,canRequestScoring, etc.useOriginatorPermissions.ts— inputs:originator.employee_role, systemADMIN. Extends base predicates withcanEditShareholders.useBondPermissions.ts—useBondListPermissionsplus the per-itemuseDraftItemPermissionshook, which checksdraft.state === 'DRAFT'and the user's role ondraft.company_id.
- Route guards —
apps/deuss-frontend/src/App.tsx(AuthenticatedRoot,VerifyAdminRoot,VerifyOriginatorRoot) gate route trees by JWT role only:
if (!isUserLoggedIn || !user?.roles?.includes('USER')) {
return <Navigate to="/login" />;
}
- Feature-level guards — e.g.
pages/bonds/components/issuanceTable/issuanceTableActions.tsx(IssuanceDraftTableActions,IssuancePublishedTableActions) disable row buttons viadisabled={!itemPermissions.canEdit}. Pure UX.
3.2 Backend authorization surface (the security boundary)
- Validators (resource-level checks, today implemented per service):
offchain/whitelabel/company-service/.../validator/CompanyValidator.kt—hasEditAccess,hasReadAccess. Real instance-level check including originator-managed companies. ✅
if (roles.isAdmin) return true
if (isEmployeeOfCompany(userId, companyId)) return true
return isOriginatorWithRole(roles, userId, companyId, EmployeeEntity.editingRoles)
- Inconsistency across endpoints (the IDOR surface):
- ✅
RegisterCompanyDocumentUploadFacade.kt— callscompanyValidator.hasEditAccess(companyId, userId). Correct pattern. - ❌
DraftDocumentFacade.kt—getDocumentsForIssuanceDraft()only checksissuanceDraftValidator.existsByIdOrNull(). No instance-level permission check — IDOR-eligible. - ❌
DraftDocumentFacade.kt—uploadanddownloadStreamconfirm "Upload ID's / document's draft matches request draft" but do not re-confirm the Subject's right to edit/read that draft. (Corroborated by the Jan-2026 internal audit finding BI-C03.) - ⚠️
OriginatorsController.kt— now class-level@Secured(IS_AUTHENTICATED)with per-method@Secured(Role.ADMIN)/@Secured([Role.ADMIN, Role.ORIGINATOR]). More granular than a blanket class-levelADMIN, but still role-only — no Resource-instance check. - Mixed in other controllers — some facades enforce, others rely on the controller's
@Secured(IS_AUTHENTICATED)and assume the frontend will filter.
- ✅
- No centralized authorization API. Search of
offchain/deuss-platform-offchain-framework/andoffchain/common/shows roles (DeussRole.kt) and@Securedannotations, but noPolicy/Authorization/Permissioninterface. Each facade calls a service-local validator with a service-local signature. Drift is structural, not accidental. ❌ Still absent.
3.3 Risk surface
If an attacker swaps companyId, issuanceDraftId, originatorId, scoringId, documentId, or marketingAssetId in a request and the backend returns or mutates the resource, this is a real data-exposure or data-integrity incident. The IDOR-eligible endpoints above are the primary candidates; DraftDocumentFacade is the confirmed live example.
4. Goal
Make the backend the single source of truth for authorization on protected resources. Frontend permission logic remains as a UX layer with explicitly documented non-security role. Every server-side decision flows through one centralized Permission Model with one PEP per controller path, evaluated on Resource instances.
5. Scope
5.1 Protected resource registry
Name and register the protected Resource types:
companyoriginatorissuance_draftcompany_documentscoring_documentdraft_documentmarketing_asset
A Resource without a registry entry has no Permission Model and therefore cannot be exposed by any PEP.
5.2 Subject-Resource Binding (the model that backs every decision)
The binding graph is the single substrate the Permission Model walks. It already exists in the database — this issue formalizes its use:
User (Subject)
├── employee(user_id, company_id, role ∈ {ADMIN, EDITOR, VIEWER}) → Company
│ ├── Company → owns → IssuanceDraft → owns → DraftDocument
│ ├── Company → owns → IssuanceDraft → owns → MarketingAsset
│ ├── Company → owns → CompanyDocument
│ └── Company → owns → Scoring → owns → ScoringDocument
│
├── originator_employee(user_id, originator_id, role ∈ {ADMIN, EDITOR, VIEWER}) → Originator
│ └── Originator → manages → Company (company.originator_id FK)
│
└── platform_role ∈ {USER, ADMIN, ORIGINATOR} (JWT-carried)
Source of truth in code:
offchain/whitelabel/company-service/.../db/migration/base schema —employee(user_id, company_id, role).- Originator migration —
originator_employee(user_id, originator_id, role)andcompany.originator_idFK. EmployeeEntity.kt—enum Role { ADMIN, EDITOR, VIEWER },editingRoles = {ADMIN, EDITOR},viewRoles = all.- Platform roles:
offchain/common/framework/.../authentication/DeussRole.kt—USER,ADMIN,ORIGINATOR.
The Permission Model resolves a decision by:
- Locating the Resource instance.
- Walking to its Parent Resource (e.g.
DraftDocument → IssuanceDraft → Company). - Looking up the Subject's role on that Parent Resource (direct
employeerow, or transitive viaoriginator_employee+company.originator_id). - Mapping
(Role, Action, ResourceState)toAllow | Deny.
5.3 Centralized Permission Model
Implement a single framework-level interface — used by every facade, no per-service shapes:
authorize(subject: Subject, resource: ResourceRef, action: Action, state?: ResourceState): Decision
Requirements:
- Input is always a Resource instance (
ResourceRef = (type, id)), never just a type. The Resource is loaded or located before the call. - The implementation walks the Subject-Resource Binding from §5.2; it does not reimplement role logic per call site.
- The PEP is invoked before any data is returned or any state is mutated — at the facade boundary, before the service layer.
- The framework prevents future endpoints from inventing local rules: the controller-to-facade boundary must call
authorize(...)or be explicitly annotated as unauthenticated (e.g. registration, healthcheck). CompanyValidator.hasEditAccess/hasReadAccessbecome the canonical implementation for thecompanyResource and are delegated to from the central model — they are not deleted; they are wrapped.
5.4 Response behavior
- For
readon sensitive Resources where existence itself is information (private draft, private scoring), return404to unauthorized Subjects (do not confirm the Resource exists). - For
create / update / delete, return403when the Subject is authenticated but unauthorized. - The choice is per-Resource and documented in the registry from §5.1, not decided ad hoc.
No security-audit section in this issue. The technical audit of access decisions is intentionally out of scope here to avoid confusion with the business UseCase audit (already wired on the frontend via
X-Use-Case-Idheaders increateAuditedRequestInit). The security-audit work is tracked separately (see #745's technical audit and the GOV analysis 1.3 Immutable Audit Trail) so the streams do not collide.
6. Functional & technical requirements
- All controllers/facades touching protected Resources call the centralized Permission Model before returning data or mutating state.
- The frontend permission hooks remain in place; their JSDoc / inline comments explicitly note they are UX-only and not the security boundary. They are not deleted — they are correctly classified.
- The Permission Model is implemented once in the offchain framework (
offchain/deuss-platform-offchain-framework/oroffchain/common/), not per service. - Test coverage includes the negative cases enumerated in §7.
7. Acceptance criteria
- Direct API call cannot fetch a foreign
company,originator, orissuance_draftby swapping the ID in the request. - Endpoints over
company_document,scoring_document,draft_document, andmarketing_assetenforce the Permission Model for every CRUD action (create,read,update,delete) on the Resource instance. - Bypassing frontend guards (calling the backend directly) does not bypass any backend permission rule.
-
DraftDocumentFacadepaths that today only checkexistsByIdOrNull(getDocumentsForIssuanceDraft,downloadStream,upload) call the Permission Model on theIssuanceDraftResource. - A negative test suite covers: wrong company, wrong originator, wrong draft, wrong role, direct API call bypassing the UI, ID swap on each protected Resource type.
- One centralized
authorize(subject, resource, action)exists in the offchain framework; no facade defines its own ad-hoc permission shape.
8. Risks & notes
- Cannot be solved by sprinkled
if user has role Xchecks in controllers. That pattern fixes visible cases and regresses with every new endpoint (the per-method@SecuredonOriginatorsControlleris exactly this kind of local rule). The point of this issue is structural. - Originator-managed companies are a real asymmetry: an
ORIGINATORSubject can haveADMIN-equivalent rights on aCompanythey manage (perCompanyValidator.isOriginatorWithRole). The Permission Model must encode this transitively, not as a special case. - Changing
404vs403for sensitivereadwill affect existing frontend error handling — coordinate with the FE team during rollout. - A threat-model review over the Resource registry from §5.1 is recommended before implementation starts, so per-Resource action-state matrices are agreed before code lands.
- Coupling: #745 (file lifecycle PEP) and #743 (API boundary) both consume this Permission Model. Land #744 first or in parallel under a shared interface. #743 is a prerequisite for trust: if the boundary admits the wrong Subject, the Permission Model authorizes the wrong principal correctly.
API Boundary Classification and Service-to-Service Authentication
743 — API Boundary Classification and Service-to-Service Authentication
Sibling issue to kont(Permission Model) and #745 (File Lifecycle). This issue is about who is allowed to call which endpoint and how that is enforced — not about what they are allowed to do once inside (#744's job). The two contracts compose: the boundary decides admittance, the Permission Model decides operations.
Audited 2026-05-29 against current code. §3.3/§3.4 corrected: internal controllers are not reachable from the public ingress (the earlier "internal endpoints exposed to the internet" framing was wrong — see §3.3). The real boundary defects are an app-vs-ingress env-gating disagreement on the debug login endpoint and an inconsistent internal-auth posture. Line-number citations removed (they rot; anchor to class/method).
1. Context
The monorepo (one frontend + a set of offchain microservices) exposes a heterogeneous HTTP surface that today has no formal API boundary model. Endpoints serve different caller classes — browser user, registered partner, another internal service, developer/debug — but the classification is not declared in source, not declared in OpenAPI, and not enforced uniformly across the ingress, OpenAPI, and application layers. The classification lives implicitly in stereotype annotations (@ExposedController, @DebugController) and in the ingress values.yaml, which do not always agree.
2. Terminology (use these terms consistently)
| Term | Meaning |
|---|---|
| Caller Class | The kind of principal making the request: browser user, partner, internal service, anonymous public, developer/debug. |
| Boundary Category | The labeled bucket a route belongs to (§5.1). Bound to a single Caller Class and a single Authentication Mechanism. |
| Authentication Mechanism | How the Caller proves identity at the wire level: user JWT, Partner Basic Auth, service mTLS / service token, or none. Distinct from authorization (covered by #744). |
| Enforcement Layer | The point at which a non-conforming request is rejected. Must hold at three layers in agreement: OpenAPI spec, gateway/ingress, application code. |
| Public ingress | Any path reachable from the internet via the Kubernetes ingress. Today every ingress entry rewrites an external /api/{service}/** prefix onto exactly one internal prefix (/api/exposed, /graphql, or /api/debug). |
| Controller stereotype | A meta-annotation that fixes a controller's mount prefix: @ExposedController = @Controller("/api/exposed"); @DebugController = @Controller("/api/debug") + @Requires(env = ["local","dev"]); plain @Controller (internal controllers) mounts at the path declared by the generated OpenAPI interface, i.e. /api/internal/**. |
| Token propagation | Micronaut feature that forwards the inbound Authorization header to outbound HTTP clients for matching service IDs. |
3. Current State (with code references)
3.1 Frontend caller surface
- Vite proxy —
frontend/apps/deuss-frontend/vite.config.tsmaps each/api/{service}to the local service port and rewrites the path to/api/exposed/.... Same external prefix used in prod ingress:
'/api/company-service': { target: 'http://localhost:8081',
rewrite: p => p.replace('/api/company-service', '/api/exposed') }
- API client —
frontend/libs/api-client/openapi-ts.config.jsgenerates the@hey-api/client-fetchSDK. All FE backend calls route through it; no rawwindow.fetch(...)to the backend was found. - Base URL + interceptors —
frontend/apps/deuss-frontend/src/App.tsx:client.setConfig({ baseUrl: VITE_BACKEND_URL })plus a request interceptor for UseCase audit headers (applyAuditHeadersToRequest). The Bearer token is injected by the SDK.
3.2 Backend authentication mechanisms
Three coexisting mechanisms today, none labeled as a Boundary Category:
- User JWT (Bearer) — global default.
offchain/common/framework/.../application-framework.yaml:
micronaut:
security:
authentication: bearer
token:
bearer: { enabled: true }
jwt:
signatures:
secret:
generator: { secret: ${JWT_GENERATOR_SIGNATURE_SECRET}, jws-algorithm: HS256 }
- Applied by controller-level
@Secured(SecurityRule.IS_AUTHENTICATED)(e.g.CompanyController). - Partner Basic Auth —
offchain/common/framework/.../authentication/PartnerBasicAuthAuthorizer.kt(Argon2-verified) +PartnerBasicAuthMethodInterceptor.ktAOP. Applied via@PartnerBasicAuthProtected. Examplewhitelabel/company-service/.../controller/partners/VerifiersController.kt:
@ExposedController
@Secured(SecurityRule.IS_ANONYMOUS) // no JWT
@PartnerBasicAuthProtected // requires Basic Auth via AOP
class VerifiersController(...)
- Credentials stored as
${VALIDATOR_USERNAME}+ Argon2-hashed${VALIDATOR_PASSWORD_HASH}(e.g.bond-issuance-service/.../application.yaml). - Service-to-service (S2S) via token propagation — same JWT, forwarded to downstream services.
bond-issuance-service/.../application.yaml:
micronaut:
security:
token:
propagation:
header: { enabled: true, header-name: Authorization }
enabled: true
service-id-regex: "company-service"
- The caller's user JWT is forwarded as-is. There is no separate service identity (no client cert, no service-account token). ⚠️ Holds.
3.3 The structural gap (today's risk surface)
The defect is not "internal endpoints are exposed to the internet" — they are not (corrected below). The defect is that the three enforcement layers do not agree, and the internal surface has no application-layer auth as defense-in-depth.
- ✅ Internal controllers are NOT reachable from the public ingress.
@ExposedControllerfixes the mount prefix to/api/exposed; the internal controllers use plain@Controllerand mount at/api/internal/**(confirmed incompany-internal.openapi.yaml:/api/internal/companies,/api/internal/employees/...). The Helm ingress (helm-chart/templates/ingress.yaml+values.yaml) rewrites every external/api/{service}/**onto an internalinttarget that is only ever/api/exposed,/graphql, or/api/debug— never/api/internal. An internet request to…/api/company-service/internal/companiesis rewritten to/api/exposed/internal/companies, which does not match/api/internal/companies, and returns 404. The internal surface is protected by network topology, not by the boundary model or by app-layer auth. - ⚠️ Internal controllers are
@Secured(IS_ANONYMOUS)with no defense-in-depth, and the posture is inconsistent.InternalCompaniesControllerandbond-issuance-service'sInternalIssuanceDraftControllerare@Secured(IS_ANONYMOUS);InternalEmployeesControlleris@Secured(IS_AUTHENTICATED). Because admittance rests on network isolation alone, any in-cluster workload, a mis-routed request, or an SSRF that reachesservice:8080/api/internal/...invokes these endpoints with no authentication. The inconsistency (two anonymous, one authenticated) shows there is no agreed internal-auth rule. - ❌ App-layer env gating and the ingress layer disagree on the debug surface.
@DebugControlleris@Requires(env = ["local","dev"])and mounts at/api/debug.LoginDebugController.debugLogin(whitelabel/user-service/.../controller/debug/) is@Secured(IS_ANONYMOUS),@Post("/auth/login"), and mints real access + refresh tokens for any email with no credential — a full authentication bypass. The bean only loads inlocal/dev, so it is absent (404) indemo/pilot/prod. But the ingress path{ name: debug, ext: /api/debug/user-service, int: /api/debug }is rendered unconditionally invalues.yaml(no environment guard). In thedevenvironment (global.environmentName: "dev") the bean is active and the path is published — so the auth-bypass is reachable on the dev public host at…/api/debug/user-service/auth/login. The app layer gates by environment; the ingress layer does not mirror it. Production safety currently depends on the@Requiresenv list and the deploy environment name coinciding, not on the ingress design. - ⚠️ OpenAPI declarations are inconsistent with enforcement:
api-specifications/.../user-service/public/user-public.openapi.yamldeclares abearerAuthsecurityScheme — matches enforcement.api-specifications/.../company-service/internal/company-internal.openapi.yamldeclares no security scheme — matches the (network-isolation-only)IS_ANONYMOUSenforcement.api-specifications/.../company-service/public/company-partners.openapi.yamlpasses the Basic Auth header as a bare parameter (name: Authorization, in: header) instead of declaring asecuritySchemes: basicAuth. Two partner services would naturally drift.
- ⚠️ CORS is globally enabled with framework defaults (
application-framework.yaml:micronaut.server.cors.enabled: true). No environment-specific origin allowlist visible. CORS is not a security boundary — but defaulting it open across all services is a missed defense-in-depth signal.
3.4 Concrete impact
The earlier draft of this issue claimed any internet client could hit …/api/{service}/internal/... and reach InternalCompaniesController. That is not true — the ingress rewrite never targets /api/internal, so those requests 404 (§3.3). The real exposures are:
- Dev auth-bypass on the public boundary. Because the debug ingress path is not environment-conditional, the
local/dev-onlyLoginDebugController.debugLoginis internet-reachable on the dev host and will mint a valid session (including forADMINusers) from an email alone. Anyone who can reach the dev ingress owns every account. Prod is saved only by the@Requiresenv list, not by the boundary. - In-cluster anonymous internal surface.
/api/internal/**trusts any caller that reaches the pod. Combined with token-propagation S2S (which forwards a user JWT with no service-vs-user distinction), an attacker with any valid user JWT, or an SSRF primitive, can pivot across internal endpoints with no service identity to stop them.
Both are exactly the failure mode this issue formalizes: admittance enforced by network topology and annotation coincidence rather than by a declared, three-layer-agreed boundary.
4. Goal
Introduce an explicit, documented, and enforced classification of API routes across the whole platform such that for every exposed endpoint it is unambiguous: who may call it, how they authenticate, where that rule is enforced (and at which layers), and where they cannot reach if they don't belong. The three enforcement layers (OpenAPI, ingress, application) must agree by construction, not by coincidence.
5. Scope
5.1 Boundary Category classification
Adopt the following categories (extensible after review):
| Category | Caller Class | Authentication Mechanism | Exposed on public ingress? |
|---|---|---|---|
anonymous_public | Anyone on the internet | None | Yes |
user_jwt | Browser/user app | User JWT (@Secured(IS_AUTHENTICATED)) | Yes |
partner_authenticated | Registered external partner | Partner Basic Auth (@PartnerBasicAuthProtected) | Yes (separate path prefix) |
service_internal | Another offchain service | Service identity (token + mTLS, or signed service token) | No (today: network-isolated /api/internal/**, but IS_ANONYMOUS) |
debug_dev_only | Local developer | None or relaxed | No — bean is @Requires(env)-gated; the ingress path must be gated to match |
Every exposed route belongs to exactly one Category. The @ExposedController / @DebugController stereotypes are the seed of this model — formalize them into the full set and make the ingress mirror the stereotype.
5.2 Three-layer enforcement (must agree)
A request that does not match its route's Category must be rejected at the earliest layer it reaches:
- OpenAPI specification. Every route's
security:block matches its Category.securitySchemesdeclaresbearerAuth,basicAuth,serviceAuthexplicitly — no bare header parameters. - Gateway / ingress. The Helm ingress maps only the Categories that belong on the public ingress.
service_internal(/api/internal/**) is already absent — keep it that way and lock it with a test.debug_dev_only(/api/debug/**) must be rendered conditionally on the environment, so the ingress path cannot exist in any internet-reachable environment where the debug beans are also active. - Application layer.
@Secured/@PartnerBasicAuthProtected/ a new@ServiceInternalannotation matches the route's Category. Aservice_internalroute rejects callers lacking a service identity even if the gateway misroutes them or an in-cluster caller reaches it directly — it must not beIS_ANONYMOUS.
5.3 Service-to-service identity
- Replace bare token-propagation-of-user-JWT for service-internal calls with a distinct service identity — at minimum a signed service-account token with a
subjectclaim that is not a user, ideally combined with mutual TLS at the service mesh (Istio is already installed cluster-side; noPeerAuthentication/AuthorizationPolicyis deployed yet). - Token propagation for
user_jwtuse cases remains, but the receiving endpoint must declare it (user_jwtCategory) — it cannot be used to reach aservice_internalendpoint.
5.4 Frontend
- Confirm there are no remaining hand-rolled
fetchpaths that bypass the generated SDK (current code map says none; inventory at PR time). - The
useFileUploadFactoryfetchcalls (used for multipart upload, see #745) stay — they go through the same auth interceptor as the SDK and route touser_jwtendpoints.
5.5 CORS
- Make CORS environment-specific and explicitly allowlisted. The current global
micronaut.server.cors.enabled: truedefault must be replaced with per-environment origin lists. - Document explicitly: CORS is not part of the API security model. It is a browser-side hint. Removing CORS does not authorize anything; tightening CORS does not authenticate anything.
6. Functional & technical requirements
- A short ADR / internal document captures §5.1 and §5.2 and is referenced from each service's README.
- Every exposed route carries Category metadata — via annotation, OpenAPI
tags/x-extension, or a central registry — and a CI check fails the build if a route has no Category. - The ingress template renders
debug_dev_onlypaths only in the environments where the corresponding@Requires(env)beans load; CI fails if adebug/internalingress path is rendered for an internet-reachable environment. - Partner endpoints use one documented authentication mechanism (Partner Basic Auth as declared
basicAuthin OpenAPI), not ad-hoc per-endpoint headers. - Service-to-service calls use a service identity distinct from any user JWT.
- Automated checks in CI prove:
service_internalroutes are unreachable from the public ingress; protected routes reject anonymous calls; OpenAPIsecurity:declarations match runtime enforcement.
7. Acceptance criteria
- Every exposed endpoint has exactly one assigned Boundary Category from §5.1.
- OpenAPI
security:declarations match runtime enforcement for all exposed routes;securitySchemesis declared forbearerAuth,basicAuth, andserviceAuth. -
service_internalcontrollers (/api/internal/**:InternalCompaniesController,InternalIssuanceDraftController,InternalEmployeesController) carry a uniform application-layer service-identity guard (@ServiceInternal, notIS_ANONYMOUS), so an in-cluster caller or a mis-routed request cannot invoke them unauthenticated. - An integration test confirms
/api/{service}/internal/...from the public URL returns 404, locking the current network isolation so a futureint:change cannot regress it. - The
debug_dev_onlysurface (@DebugController, e.g.LoginDebugController.debugLogin) cannot reach the public ingress in any environment: the/api/debug/**ingress path is rendered only where the debug beans are@Requires-active and never on an internet-reachable host. Verified by a test against the deployed dev ingress (expects 404 on/api/debug/user-service/auth/login). - Partner APIs declare and use a single
basicAuthsecurityScheme; bareAuthorizationheader parameters are removed from partner OpenAPI specs. - Service-to-service calls use a service identity distinct from user JWT propagation for any endpoint not in the
user_jwtCategory. - CORS is environment-specific with an explicit origin allowlist; the global
cors.enabled: truedefault is removed. - Automated CI checks fail if (a) a route has no Category, (b) a
service_internalordebug_dev_onlyroute is mapped on an internet-reachable ingress, or (c) OpenAPIsecuritydiverges from the annotation.
8. Risks & notes
- The flagship correction: internal endpoints are not internet-exposed today (the ingress never targets
/api/internal). Do not scope this issue around that false premise. The real defects are (1) the debug auth-bypass whose ingress path is not environment-gated, and (2)IS_ANONYMOUSinternal routes with no app-layer defense-in-depth. Both are "layers don't agree" problems, which is precisely what the boundary model fixes. LoginDebugController.debugLoginis an unauthenticated token-minting endpoint. Even gated tolocal/dev, it must never be one ingress-misconfiguration away from an internet-reachable environment. Treat hardening the debug ingress gating as the highest-priority item.- Historical and undocumented endpoints must be inventoried first. Recommend a one-pass audit per service before planning implementation order.
- Splitting/gating the ingress may affect local developer flow —
vite.config.tsassumes a single/api/{service}prefix. Plan a migration note and document the new local dev story. - Tightly coupled to #744: the Permission Model assumes the inbound Subject is whoever the boundary says it is. If the boundary lies (service identity spoofable, debug bypass reachable), #744 cannot help. Land #743's boundary fixes before, or in lockstep with, #744's enforcement work.
- The token-propagation reconfiguration carries the highest blast radius — any service mis-configured to require service identity while still receiving user JWTs from another internal caller will break. Roll out per service pair with feature flags.
API Design
OpenAPI Conventions
OpenAPI schema conventions are maintained in the internal GitLab wiki for component-level API documentation.
The API design work covers several topics with a focus on security and design patterns.
More detailed follow-up materials exist in private work items.
Identity management content has been moved to Identity Management.