System Security
Overview
This page describes the architectural choices behind Kinotic's IAM. It is the why; for the operational how (signup flows, login flows, endpoint reference) see Organization Management.
The platform supports email/password and OIDC authentication, isolates user pools by scope, stores credentials separately from user records, and uses standalone OidcConfiguration entities that are referenced (not embedded) by the scopes that consume them.
Three Scope Layers
Every IamUser carries a scope tag, expressed as the pair (authScopeType, authScopeId):
| Layer | Scope Type | Who | Auth Path |
|---|---|---|---|
| Organization | ORGANIZATION | Customer org admins and members | Email/password or OIDC (platform-social or per-org SSO) — see Organization Management |
| Application | APPLICATION | End-users of an application owned by an org | Email/password or OIDC — credentials and OIDC configs are scoped to the application |
| System | SYSTEM | Platform operators running kinotic-server itself | Out of scope for OIDC; handled via a separate, infrastructure-tier path (deferred) |
A user with email jane@example.com in Organization A is a fundamentally different identity from jane@example.com in Application B. They have separate IamUser rows, separate IamCredential rows, and separate authentication paths. This prevents accidental cross-scope access and lets the same person have different authentication methods at different scopes (e.g. password at the org, federated SSO at one of the org's apps).
How users are differentiated
A user's identity is determined by the combination of three values: email + scope type + scope id. The same email address can exist many times across the platform — each combination produces a completely separate user record with its own credentials, enabled state, and OIDC links.
For example, the same person could carry four distinct identities:
- A developer in their organization (
ORGANIZATION/ org-A-id) - A test account in one of their applications (
APPLICATION/ app-A-id) - A customer end-user in a different application (
APPLICATION/ app-B-id) - A second org as the same person via social-OIDC (
ORGANIZATION/ org-B-id)
Each is a separate row. Disabling one does not disable the others. Authentication against one does not grant access to any of the others. Scope isolation is enforced at every lookup — there is no query that returns users across scopes.
For OIDC users, the platform additionally tracks (oidcSubject, oidcConfigId) so that one social identity can map to multiple IamUser rows in different orgs. The primary flag on IamUser selects which row is the default landing for that identity at login.
Why three layers
Two layers (platform vs tenant) would force organizations and their applications to share a user pool. That doesn't work because:
- An organization's developers should not automatically be end-users of every application they manage.
- Application end-users should not have access to organization-level tooling.
- Different applications under the same organization may serve completely different user populations.
Three layers map directly to real-world trust boundaries: the platform operator trusts the infrastructure, the organization trusts its developers, and the application trusts its users.
Authentication Methods
Both methods are available at the Organization and Application layers:
- Email/Password — Credentials are verified against bcrypt hashes. Password hashes are stored in a separate
IamCredentialentity (a different Elasticsearch index) and are architecturally invisible to the rest of the system. User CRUD operations have no way to accidentally expose credentials. - OIDC — JWT tokens are validated against
OidcConfigurationentities referenced by the target scope. The platform verifies signature, issuer, audience, and expiration via JWKS.
Every authentication request includes scope headers (authScopeType, authScopeId) that determine which user pool and which OIDC configurations to check. For OIDC initiated through the kinotic-server's own routes (/api/login/start/:provider), these are derived server-side from the matched IamUser; for direct STOMP CONNECT they're supplied by the client.
OIDC Configuration
OidcConfiguration is a standalone, reusable entity with no embedded scope or ownership. Association is by reference — each scope-bearing entity (KinoticSystem, Organization, Application) holds a list of oidcConfigurationIds.
Why standalone
Embedding configs inside scope entities would require duplication when the same provider serves multiple scopes, and updating a provider's settings would mean updating every copy. Sharing/inheritance models add complexity — who owns the config? what happens when it's modified? — without clear benefit.
The standalone model is simpler: a config exists once, and any number of scopes can reference it by id. Add the id to enable; remove to disable. No ownership, no inheritance, no cascading updates.
Two roles, one entity
The same OidcConfiguration shape serves two distinct roles, distinguished by which scope holds the reference:
- Platform OIDC — referenced from
KinoticSystem.oidcConfigurationIds. Shows as a "Continue with X" button on the login/signup pages. Bootstrapped at startup byPlatformOidcBootstrapfromkinotic.oidc.platformProviders[]in the helm/Spring config; client secret is read from disk and persisted viaSecretStorageService. Currently only social providers (Google, Microsoft consumer) are intended here. - Per-org SSO — referenced from
Organization.oidcConfigurationIds. Reached via the email-first lookup flow when the user's primaryIamUseris OIDC-typed and their org has a live config. Never shown as a global button. The admin UI for managing these is pending.
There is no boolean flag distinguishing the roles. Code paths that want "only the buttons to show" call KinoticSystemService.getOidcConfigurations(); per-org flows call OrganizationService.getOidcConfigurations(orgId).
Sign-Up
Self-service signup is wired up for the Organization scope. Both email/password and social-IdP entry points create the Organization together with the founding IamUser — see Organization Management for the step-by-step.
For the Application scope, end-user provisioning is still admin-driven: an org admin (or the application's own admin tooling) creates IamUser rows in the application's scope. OIDC self-service into application scope is supported by the data model (PendingRegistration + UserProvisioningMode.AUTO/REGISTRATION_REQUIRED) but the per-application admin UI for enabling it is pending.
Design Decisions
Credential separation
Password hashes are stored in a separate IamCredential entity, keyed by user id and not exposed through any published service interface. The user entity is part of the public API — returned by CRUD operations, displayed in UIs, passed around in service calls. Storing credentials separately means password hashes are architecturally invisible to the rest of the system.
Scope as string fields
The user entity stores authScopeType and authScopeId as plain string fields rather than typed references. This avoids coupling IamUser to Organization/Application, keeps queries simple (term filters on keyword fields), and lets the scope model evolve without migrating user records.
Single user entity for all scopes
All three scope layers share the same IamUser type, distinguished by the scope fields. Separate user types per scope would triple the service interfaces and implementations with no behavioral difference — the authentication logic is identical across scopes.
Multi-org identity keyed by (oidcSubject, oidcConfigId)
For OIDC users, identity across org-scoped IamUser rows is keyed by the OIDC sub claim plus the configId — not email. Email is mutable at the IdP and the same email across two IdP tenants is two different people. The sub is stable within an issuer, and pairing it with the configId disambiguates issuers. The primary boolean on IamUser selects the default-landing row for a given identity.
Auto-derived organization id
The Organization.id is derived from the user-supplied name (slugified) at signup time. This supports subdomain-based tenant identification (e.g. customer.kinotic.ai) without surfacing UUIDs in URLs.
JWT carries aud=kinotic
Kinotic-minted JWTs (the 60s tickets passed from OIDC callback to STOMP CONNECT) include an aud claim of kinotic so that any non-Kinotic JWT — even a valid IdP token — is rejected at the gateway.
Bootstrap
A new deployment is bootstrapped via two mechanisms running in order:
kinotic-migrationruns to completion before kinotic-server starts. It creates all Elasticsearch indices and seeds the singletonKinoticSystemrow plus a default system admin (currently the onlySYSTEM-scoped user; will be revisited as part of the system-auth track).PlatformOidcBootstrapruns at kinotic-server startup. For each entry inkinotic.oidc.platformProviders[]it reads the matching client secret fromkinotic.oidc.secretsPath/<id>, stores it inSecretStorageServiceunder(scope=configId, key="clientSecret"), upserts theOidcConfigurationentity, and ensuresKinoticSystem.oidcConfigurationIdsreferences it. The bootstrap is idempotent — restart re-applies and corrects drift.
For the operator-side instructions (bare local, KinD, Azure), see docs/local-oidc-setup.md in the repo.
Network Security
- All client-to-server communication occurs over WebSocket with TLS in any non-local environment.
- The api-gateway port (default
58503) carries STOMP/WebSocket on/v1and the auth REST endpoints on/api/*. - TLS termination is handled at the ingress layer (KinD: mkcert + nginx; Azure: cert-manager + LoadBalancer).
What This Design Does Not Cover
- Authorization — Roles, policies, RBAC, and ABAC policy engine integration. The authenticated session currently carries an empty roles list; future work populates it from the policy system.
- Groups — Group entities and membership management.
- System-scope OIDC — Platform operators do not authenticate through any of the OIDC flows above; that's a separate, infrastructure-tier authentication path (deferred).
- Account linking — Linking an existing local account to a social identity is on the roadmap but not built.