Skip to main content

Access Control

Security Reference Material

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 the partners table 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 of BROKER, PSP, or GUARANTOR.
  • 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_id does 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_id and the partner_role (linking to broker_details, psp_details, etc.).
  • Certificate Issuance: During the technical integration process, these two attributes (partner_id and partner_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_id and partner_role as 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_role header to decide if the caller is allowed to trigger the endpoint.
    • Tenant Isolation: Evaluates the partner_id header against its local database to verify if the caller has ownership rights over the specific entity being manipulated.

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_ADMIN role).

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)

  1. The DEUSS Root CA (stored in OpenBao) is mounted locally in every DEUSS Core service container as the public certificate root-ca.crt.
  2. The partner submits a CSR, and DEUSS signs and issues a JWT signing certificate.

Phase 2: Sending from the Broker Platform Application

  1. The Broker Platform backend builds the JWT (payload + header with x5c), signs it with its private key, and places it in the HTTP header X-End-User-Context.
  2. The request is sent to the DEUSS platform through a persistent pre-established mTLS tunnel.

Phase 3: Istio Gateway

  1. 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-abc
    • X-Partner-Role: BROKER
  2. Istio forwards the request, including the unchanged X-End-User-Context header, into the internal network to the target DEUSS Core microservice.

Phase 4: Cryptographic Verification in DEUSS Core

  1. The request enters the service container and is intercepted by a central security filter.
  2. The filter extracts the partner certificate from x5c[0] and verifies with the local root-ca.crt that the certificate was issued by the DEUSS platform and is still valid.
  3. The filter extracts the public key from the partner certificate and uses it to verify the digital signature of the entire JWT.
  4. 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

  1. The service receives verified data: the tenant identity (X-Partner-Id) and the end-user identity (sub).
  2. The service performs ownership-based authorization by verifying in the database or Indexer that the user (sub) is allowed to manipulate the requested resource.
  3. 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: CompanyValidator is still the only real instance-level check; Issuer/Originator/IssuanceDraftValidator are existence-only; DraftDocumentFacade is still IDOR-eligible; there is still no centralized authorize(subject, resource, action). One minor drift applied (OriginatorsController authorization 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)

TermMeaning
SubjectAuthenticated principal making the request (user or service account). Identified by userId and the Roles set carried on the JWT (USER, ADMIN, ORIGINATOR).
ResourceA protected business entity instance — e.g. Company#123, IssuanceDraft#456. Always identified by (type, id), never by type alone.
Parent ResourceThe 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).
ActionA 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 ModelThe 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 BindingThe persistent graph that determines which Subjects have which roles on which Parent Resources. See §4.2.
IDORInsecure 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. Exposes canEditGeneralInfo, canEditDocuments, canEditEmployees, canSendForValidation, canDeleteCompany, canEditScoringDocuments, canRequestScoring, etc.
    • useOriginatorPermissions.ts — inputs: originator.employee_role, system ADMIN. Extends base predicates with canEditShareholders.
    • useBondPermissions.tsuseBondListPermissions plus the per-item useDraftItemPermissions hook, which checks draft.state === 'DRAFT' and the user's role on draft.company_id.
  • Route guardsapps/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 via disabled={!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.kthasEditAccess, 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 — calls companyValidator.hasEditAccess(companyId, userId). Correct pattern.
    • DraftDocumentFacade.ktgetDocumentsForIssuanceDraft() only checks issuanceDraftValidator.existsByIdOrNull(). No instance-level permission check — IDOR-eligible.
    • DraftDocumentFacade.ktupload and downloadStream confirm "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-level ADMIN, 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/ and offchain/common/ shows roles (DeussRole.kt) and @Secured annotations, but no Policy / Authorization / Permission interface. 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:

  • company
  • originator
  • issuance_draft
  • company_document
  • scoring_document
  • draft_document
  • marketing_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) and company.originator_id FK.
  • EmployeeEntity.ktenum Role { ADMIN, EDITOR, VIEWER }, editingRoles = {ADMIN, EDITOR}, viewRoles = all.
  • Platform roles: offchain/common/framework/.../authentication/DeussRole.ktUSER, ADMIN, ORIGINATOR.

The Permission Model resolves a decision by:

  1. Locating the Resource instance.
  2. Walking to its Parent Resource (e.g. DraftDocument → IssuanceDraft → Company).
  3. Looking up the Subject's role on that Parent Resource (direct employee row, or transitive via originator_employee + company.originator_id).
  4. Mapping (Role, Action, ResourceState) to Allow | 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 / hasReadAccess become the canonical implementation for the company Resource and are delegated to from the central model — they are not deleted; they are wrapped.

5.4 Response behavior

  • For read on sensitive Resources where existence itself is information (private draft, private scoring), return 404 to unauthorized Subjects (do not confirm the Resource exists).
  • For create / update / delete, return 403 when 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-Id headers in createAuditedRequestInit). 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/ or offchain/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, or issuance_draft by swapping the ID in the request.
  • Endpoints over company_document, scoring_document, draft_document, and marketing_asset enforce 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.
  • DraftDocumentFacade paths that today only check existsByIdOrNull (getDocumentsForIssuanceDraft, downloadStream, upload) call the Permission Model on the IssuanceDraft Resource.
  • 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 X checks in controllers. That pattern fixes visible cases and regresses with every new endpoint (the per-method @Secured on OriginatorsController is exactly this kind of local rule). The point of this issue is structural.
  • Originator-managed companies are a real asymmetry: an ORIGINATOR Subject can have ADMIN-equivalent rights on a Company they manage (per CompanyValidator.isOriginatorWithRole). The Permission Model must encode this transitively, not as a special case.
  • Changing 404 vs 403 for sensitive read will 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)

TermMeaning
Caller ClassThe kind of principal making the request: browser user, partner, internal service, anonymous public, developer/debug.
Boundary CategoryThe labeled bucket a route belongs to (§5.1). Bound to a single Caller Class and a single Authentication Mechanism.
Authentication MechanismHow 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 LayerThe point at which a non-conforming request is rejected. Must hold at three layers in agreement: OpenAPI spec, gateway/ingress, application code.
Public ingressAny 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 stereotypeA 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 propagationMicronaut 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 proxyfrontend/apps/deuss-frontend/vite.config.ts maps 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 clientfrontend/libs/api-client/openapi-ts.config.js generates the @hey-api/client-fetch SDK. All FE backend calls route through it; no raw window.fetch(...) to the backend was found.
  • Base URL + interceptorsfrontend/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 Authoffchain/common/framework/.../authentication/PartnerBasicAuthAuthorizer.kt (Argon2-verified) + PartnerBasicAuthMethodInterceptor.kt AOP. Applied via @PartnerBasicAuthProtected. Example whitelabel/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. @ExposedController fixes the mount prefix to /api/exposed; the internal controllers use plain @Controller and mount at /api/internal/** (confirmed in company-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 internal int target that is only ever /api/exposed, /graphql, or /api/debugnever /api/internal. An internet request to …/api/company-service/internal/companies is 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. InternalCompaniesController and bond-issuance-service's InternalIssuanceDraftController are @Secured(IS_ANONYMOUS); InternalEmployeesController is @Secured(IS_AUTHENTICATED). Because admittance rests on network isolation alone, any in-cluster workload, a mis-routed request, or an SSRF that reaches service: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. @DebugController is @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 in local/dev, so it is absent (404) in demo/pilot/prod. But the ingress path { name: debug, ext: /api/debug/user-service, int: /api/debug } is rendered unconditionally in values.yaml (no environment guard). In the dev environment (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 @Requires env 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.yaml declares a bearerAuth securityScheme — matches enforcement.
    • api-specifications/.../company-service/internal/company-internal.openapi.yaml declares no security scheme — matches the (network-isolation-only) IS_ANONYMOUS enforcement.
    • api-specifications/.../company-service/public/company-partners.openapi.yaml passes the Basic Auth header as a bare parameter (name: Authorization, in: header) instead of declaring a securitySchemes: 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:

  1. Dev auth-bypass on the public boundary. Because the debug ingress path is not environment-conditional, the local/dev-only LoginDebugController.debugLogin is internet-reachable on the dev host and will mint a valid session (including for ADMIN users) from an email alone. Anyone who can reach the dev ingress owns every account. Prod is saved only by the @Requires env list, not by the boundary.
  2. 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):

CategoryCaller ClassAuthentication MechanismExposed on public ingress?
anonymous_publicAnyone on the internetNoneYes
user_jwtBrowser/user appUser JWT (@Secured(IS_AUTHENTICATED))Yes
partner_authenticatedRegistered external partnerPartner Basic Auth (@PartnerBasicAuthProtected)Yes (separate path prefix)
service_internalAnother offchain serviceService identity (token + mTLS, or signed service token)No (today: network-isolated /api/internal/**, but IS_ANONYMOUS)
debug_dev_onlyLocal developerNone or relaxedNo — 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:

  1. OpenAPI specification. Every route's security: block matches its Category. securitySchemes declares bearerAuth, basicAuth, serviceAuth explicitly — no bare header parameters.
  2. 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.
  3. Application layer. @Secured / @PartnerBasicAuthProtected / a new @ServiceInternal annotation matches the route's Category. A service_internal route rejects callers lacking a service identity even if the gateway misroutes them or an in-cluster caller reaches it directly — it must not be IS_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 subject claim that is not a user, ideally combined with mutual TLS at the service mesh (Istio is already installed cluster-side; no PeerAuthentication/AuthorizationPolicy is deployed yet).
  • Token propagation for user_jwt use cases remains, but the receiving endpoint must declare it (user_jwt Category) — it cannot be used to reach a service_internal endpoint.

5.4 Frontend

  • Confirm there are no remaining hand-rolled fetch paths that bypass the generated SDK (current code map says none; inventory at PR time).
  • The useFileUploadFactory fetch calls (used for multipart upload, see #745) stay — they go through the same auth interceptor as the SDK and route to user_jwt endpoints.

5.5 CORS

  • Make CORS environment-specific and explicitly allowlisted. The current global micronaut.server.cors.enabled: true default 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_only paths only in the environments where the corresponding @Requires(env) beans load; CI fails if a debug/internal ingress path is rendered for an internet-reachable environment.
  • Partner endpoints use one documented authentication mechanism (Partner Basic Auth as declared basicAuth in 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_internal routes are unreachable from the public ingress; protected routes reject anonymous calls; OpenAPI security: 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; securitySchemes is declared for bearerAuth, basicAuth, and serviceAuth.
  • service_internal controllers (/api/internal/**: InternalCompaniesController, InternalIssuanceDraftController, InternalEmployeesController) carry a uniform application-layer service-identity guard (@ServiceInternal, not IS_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 future int: change cannot regress it.
  • The debug_dev_only surface (@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 basicAuth securityScheme; bare Authorization header 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_jwt Category.
  • CORS is environment-specific with an explicit origin allowlist; the global cors.enabled: true default is removed.
  • Automated CI checks fail if (a) a route has no Category, (b) a service_internal or debug_dev_only route is mapped on an internet-reachable ingress, or (c) OpenAPI security diverges 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_ANONYMOUS internal routes with no app-layer defense-in-depth. Both are "layers don't agree" problems, which is precisely what the boundary model fixes.
  • LoginDebugController.debugLogin is an unauthenticated token-minting endpoint. Even gated to local/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.ts assumes 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.