Multi-Tenancy
Workspace Model
Section titled “Workspace Model”Logrite uses a workspace-based multi-tenancy model where each organization has an isolated workspace. All data — scans, logs, agents, conversations — is scoped to a workspace.
Isolation Layers
Section titled “Isolation Layers”1. Backend API — Workspace Scoping
Section titled “1. Backend API — Workspace Scoping”Every API request carries a JWT that includes workspaceId. The Backend API filters all queries by workspace:
// All endpoints enforce workspace isolation@GetMapping("/scans")public List<Scan> getScans(@AuthenticatedWorkspace String workspaceId) { return scanRepository.findByWorkspaceId(workspaceId);}2. OpenSearch — Index Naming
Section titled “2. OpenSearch — Index Naming”Every tenant index is named <family>-<workspaceId>, with a per-agent variant
log-analytics-<workspaceId>-<key>. The families (see com.logwrite.util.IndexNames) are:
log-analytics-<workspaceId>[-<key>] # collector-agent + uploaded log documentstrace-analytics-raw-<workspaceId> # traces / spanslog-cloud-platform-<workspaceId> # AWS/Netlify/Vercel logslog-observability-<workspaceId> # logs pulled from a customer's own vendoragent-flags-<workspaceId> # compliance flagsanalytics-state-<workspaceId> # saved Analytics UI statewarden-requests-<workspaceId> # governed LLM requestsrelay-compliance-events-<workspaceId> # relay compliance eventscortex-traces-<workspaceId> # Cortex turns — per USER, see below (2026-10-14)For example, workspace 3103 uses log-analytics-3103-<...>. The shared, world-readable sample
indices payment_queries, external_service_calls, database_queries, exceptions (and their
_semantic variants) carry NO per-tenant data.
Enforcement is not by name alone. All OpenSearch access goes through the backend proxy
(/api/v1/opensearch/**), whose OpenSearchIndexAuthorizer resolves every index a request would
read — from the path AND from _bulk/_msearch/_mget/_reindex/_sql bodies — and requires
each to belong to the caller’s JWT workspace (opensearch.proxy.enforce-jwt=true); cross-workspace
or over-broad wildcard targets get 403. As of the 2026-09-08 security remediation the
semantic-search API and the MCP server enforce the same per-workspace scoping (both were
previously unauthenticated), and the JWT signing key is a per-environment secret
(SECURITY_JWT_SECRET_KEY), no longer the committed placeholder.
The family list is duplicated: com.logwrite.util.IndexNames.WS_FAMILIES in the backend and
config.WS_FAMILIES in the MCP server. A one-sided edit is silent, so the two move together.
cortex-traces is per user (2026-10-14)
Section titled “cortex-traces is per user (2026-10-14)”cortex-traces-<workspaceId> holds every member’s Cortex turns, and a member may read only
their own. It joined WS_FAMILIES on both sides on 2026-10-14 (so MCP’s analytics_query can
read it), and because index authorisation stops at the workspace, the proxy narrows further in
CortexTracesScope, after authorisation and before forwarding:
- It judges every index the request names — the path,
_msearch/_mget/_bulkbodies, SQL/PPLFROM, query-DSL lookups — throughOpenSearchIndexAuthorizer.extractIndexTokens. A path-only check would leave root/_msearch,/_mgetand_plugins/_sql FROM cortex-traces-…reading every member’s turns. - The only shape allowed to reach the family is an index-first
<target>/_searchor/_countwhose only route to it is the path. Its query is rewritten to{"bool":{"must":[<original or match_all>],"filter":[<not a cortex-traces index OR userId = caller>]}}, keeping every other top-level key (aggs,size,sort,_source, …). The caller isOwnershipService.currentUser, so an API key resolves to its minter. Hits from other indices in the same search are untouched. - Everything else that reaches it is 403
{"error":"cortex_traces_search_only"}— no other shape can carry the filter. - A pattern whose literal prefix rules the family out (
log-analytics-3103-*) does not reach it and is forwarded byte for byte._mappingand_catname no data and stay readable.
The semantic-search API is deliberately excluded: cortex-traces is not in its
tenantScope.ts, because semantic search cannot apply the per-user filter.
OpenSearch objects by id: the ownership registry (2026-10-15)
Section titled “OpenSearch objects by id: the ownership registry (2026-10-15)”Monitors, anomaly detectors and ISM policies are addressed by id, not by index, so the index gates
above never saw them: any workspace could read, run, stop or delete another’s by id through the
proxy. OpenSearchObjectOwnershipService (wired into ProxyGates.refuse after the notification
gate) now guards:
/_plugins/_alerting/monitors/{id}and its/_executeand/_acknowledge/alerts/_plugins/_anomaly_detection/detectors/{id}and its/_start,/_stop,/_profile,/_preview(also the detector start/stop inDashboardsTenantAuthorizer)/_plugins/_ism/policies/{id}
The registry is the table opensearch_object_ownership(object_type, object_id, workspace_id, created_at), primary key (object_type, object_id), types monitor / detector / ism_policy.
A 2xx create through the proxy records the owner (the response _id for monitors and detectors,
the path id for a new ISM policy); a delete removes the row.
A by-id call is allowed when the registry owner is the caller’s workspace; or, with no row, when the
object is strictly owned — it names at least one index and every one belongs to the workspace
(or is the workspace’s own wildcard); or, for an ISM PUT only, when the id does not exist upstream
(a create). Otherwise 403 {"error":"forbidden","reason":"cross-workspace access denied"}.
A blank workspace always denies — here and, since the same day, in NotificationOwnershipService.
ISM policy bodies are checked too: every policy.ism_template[].index_patterns entry must be
strictly owned. Listings (SEARCH_HITS for monitors and detectors, ISM_POLICIES) keep only
registry-owned or untracked-and-strictly-owned objects, so cluster-global wildcard objects
(agent-flags-* monitors, the CLI *-retention ISM policies) are visible to no workspace.
3. AI Service — Conversation Isolation
Section titled “3. AI Service — Conversation Isolation”Conversations in the AI Service are tied to conversationId which includes the workspace context. The Backend API enforces that users can only access conversations from their workspace.
4. Agents — Per-Workspace Configuration
Section titled “4. Agents — Per-Workspace Configuration”Agents (see AI Agents) are owned by a workspace. An agent created in workspace A cannot access data from workspace B. Agent execution logs are also workspace-scoped.
Authentication Flow
Section titled “Authentication Flow”sequenceDiagram
participant User
participant Frontend
participant Backend
participant Services
User->>Frontend: Login (email + password)
Frontend->>Backend: POST /api/v1/auth/login
Backend-->>Frontend: JWT {userId, workspaceId, role}
Frontend->>Frontend: Store JWT in Redux
Frontend->>Backend: Any request + Authorization: Bearer {JWT}
Backend->>Backend: Validate JWT, extract workspaceId
Backend-->>Frontend: Workspace-scoped response
Frontend->>Services: AI Service, Semantic Search (with workspaceId header)
RBAC (Role-Based Access Control)
Section titled “RBAC (Role-Based Access Control)”| Role | Permissions |
|---|---|
| Admin | Full workspace control — manage users, agents, integrations |
| Analyst | Read/write scans, conversations, log queries |
| Viewer | Read-only access to scans and dashboards |
Agent-specific permissions extend this model — see Agent Multi-Tenancy.
Rate Limiting (Planned)
Section titled “Rate Limiting (Planned)”Per-workspace rate limits will be enforced at the API Gateway level:
| Tier | Scans/day | AI Queries/hour | Agents |
|---|---|---|---|
| Free | 10 | 100 | 2 |
| Pro | 100 | 1,000 | 20 |
| Enterprise | Unlimited | Unlimited | Unlimited |
Security hardening (Jul 2026 audit)
Section titled “Security hardening (Jul 2026 audit)”The relay + collector-agent audit closed several tenant-isolation gaps:
- OpenSearch proxy — the per-workspace
OpenSearchIndexAuthorizernow enforces (403s cross-workspace index access) by default (opensearch.proxy.enforce-jwt→true; setOPENSEARCH_PROXY_ENFORCE_JWT=truein prod to activate the cutover). Previously it ran in shadow mode (loggedwould_deny, still forwarded), so any authenticated user could read another workspace’s data through the client-supplied index path. - Fail-closed workspace resolution — a JWT without a
workspaceIdclaim and no unambiguous single workspace is rejected (401) instead of silently resolving to the sharedlogritetenant, which had merged distinct principals into one global tenant. - Warden control plane — removed the
warden-requests-*cross-tenant wildcard fallback; provider-credential / relay-key secrets bind to their workspace via GCM AAD. - Collector agents — agent
keyis unique per-workspace (not globally); host-inventory assets are namespaced by workspace.
Workspace lifecycle: what deleting an account leaves behind
Section titled “Workspace lifecycle: what deleting an account leaves behind”Membership is the only route into a workspace. There is no owner column on workspaces
— ownership is the workspace_user join plus a row in workspace_member_roles — which is
what makes the scoping in the sections above work, and also what makes an empty workspace a
problem rather than a loose end.
AccountDeletionService detaches the departing user (membership + roles) and nulls their
home workspace_id. It used to stop there, recording that an emptied personal workspace
“is harmless and can be reaped separately”. No reaper was ever written. Measured on dev:
select count(*) from workspaces wwhere not exists (select 1 from workspace_user wu where wu.workspace_id = w.workspace_id);→ 20Twenty workspaces with zero members. “Harmless” was the wrong word: nobody can open, share or delete such a workspace through the product, because every one of those actions is authorised by a membership that no longer exists — while its projects, agents and scan rows stay on disk and in the billing tables indefinitely.
A workspace the departure leaves with zero members is now purged through the existing
deleteWorkspace, so there is one definition of what a workspace is made of.
Only a claimable invitation keeps a workspace alive
Section titled “Only a claimable invitation keeps a workspace alive”An invited address that has not accepted yet is a member who has not arrived; reaping on emptiness
alone would delete a workspace out from under someone mid-invite. But only an invitation that can
still be claimed means that. Until 2026-10-16 any row in user_invitations spared the
workspace — ACCEPTED and REVOKED ones included — so an account whose invitee had already
joined and left kept its workspace for ever (dev: 20 of 21 memberless workspaces had no claimable
invitation).
| workspace after the detach | outcome |
|---|---|
| other members remain | kept — it was shared, not personal |
no members, an invitation PENDING and not expired (expires_at NULL or in the future) | kept for the invitee, logged at INFO |
no members, only ACCEPTED / REVOKED / expired invitations, or none | purged |
The predicate is MemberlessWorkspaces.CLAIMABLE_INVITATION, the SQL twin of
UserInvitation.isClaimable(); “now” is bound from the JVM, not LOCALTIMESTAMP, so the database
session time zone cannot move the expiry. The decision lives in MemberlessWorkspaces (toPurge
returns ids; the caller performs the purge) so it is testable without standing up a deletion.
Why the purge is a self-invocation
Section titled “Why the purge is a self-invocation”reapMemberlessWorkspaces calls deleteWorkspace on this, so it does not pass through
the transaction proxy. That is intended, not an oversight to route around with a
self-injected bean: deleteWorkspace is @Transactional with the default REQUIRED, so a
proxied call would join deleteAccount’s transaction anyway, and running it inline keeps the
account and its emptied workspaces a single atomic unit. A failure part-way cannot leave an
account deleted with its workspaces half-purged.
A static-analysis rule (Sonar java:S6809) flags the pattern. It is correct about the
mechanism and wrong about the intent here.
What deleting a workspace removes
Section titled “What deleting a workspace removes”AccountDeletionService.deleteWorkspace is the one definition of a workspace’s footprint, used by
account deletion and by the reaper below. Since 2026-10-16 it also:
- removes project groups — member-project grants (
workspace_member_projects) are deleted, the workspace’s projects are detached from their groups, thenproject_grouprows are deleted. Before, theproject_group → workspacesforeign key made the whole account deletion roll back (a 5xx) for any workspace that had a group; - revokes every workspace credential instead of leaving it live: API keys (
sandbox_api_key), Gatekeeper CI keys (gatekeeper_ci_key), coding ingest tokens (coding_ingest_token) and MCP grants (mcp_grant) are marked revoked — rows are kept for audit, not deleted — alongside the existing session revoke. Any revocation logs[workspace-delete] revoked credentials for workspace_id=… api_keys=… ci_keys=… ingest_tokens=… mcp_grants=…; - leaves
workspace_subscriptionalone — billing state is not the deletion’s to rewrite; - deletes the workspace’s policies (since 2026-10-15) —
policy_rule,policy_assignment,policy_versionandpolicyrows of the workspace, the scan-profile policies of its projects, and everycollectorassignment to its agents (AccountDeletionService.deleteWorkspace); - purges the workspace’s OpenSearch data after the transaction commits (next section).
The SQL lives in WorkspaceRemnants (detachGroups, revokeAccess).
A repository row shared with another workspace (2026-10-23)
Section titled “A repository row shared with another workspace (2026-10-23)”code_repository holds one row per repository URL, shared by every workspace that scans it;
its user_id records who connected it first, not who owns it. Account deletion removes only rows
no surviving project uses and keeps the others. Until backend bb8163f1 a kept row still named
the deleted user, so DELETE FROM users hit the code_repository.user_id foreign key and the
whole account deletion rolled back with a 500 — measured on dev when a QA account that had
first connected Java-Techie-jt/spring-boot-crud-example was deleted while four other workspaces
used the row. The kept row now passes to the owner of a surviving project that uses it
(WorkspaceDeletionPostgresTest.sharedRepositoryRowPassesToASurvivingOwner).
OpenSearch data goes with the workspace
Section titled “OpenSearch data goes with the workspace”The privacy policy promises deletion “within a commercially reasonable period”. deleteWorkspace
never touched OpenSearch, so every per-workspace index, monitor, detector, notification channel and
Dashboards saved object outlived its workspace. Measured 2026-10-16: dev held 667 indices (219.5 MB)
of workspace 2903, prod 284 log-analytics-3103-* indices (184.6 MB, 1.22 M docs) — neither
workspace existed on that estate.
WorkspaceOpenSearchPurger.purgeAfterCommit(wid) registers an after-commit hook, so a rolled-back
deletion purges nothing; a failure in the purge is logged ([ws-os-purge] workspace=… purge failed)
and left to the orphan janitor. purge(ws):
- Refuses a segment that is not 1–9 digits (
not_numeric— the legacylogritetenant is never purged), when OpenSearch credentials are missing (no_opensearch_credentials), and when the workspace still exists (workspace_exists, checked in a fresh transaction). - Per index family, resolves
<family>-<ws>and<family>-<ws>-*and keeps only names whose workspace segment is exactlyws(IndexNames.extractWorkspaceSegment) — so purging12never toucheslog-analytics-123orlog-analytics-1. Data streams are logged, never deleted. - Deletes those indices by explicit name, in comma-separated batches of ≤ 3000 characters (a 404 retries one by one; 404 means already gone). No wildcard DELETE is ever sent.
- Deletes the workspace’s retention ISM policy and index template (404 ignored; never the legacy shared id).
- Deletes every object in the three ownership tables (
opensearch_object_ownership,saved_object_ownership,notification_config_ownership): monitors, detectors (stopped first), ISM policies, notification configs, Dashboards saved objects. The ownership row is deleted on 2xx or 404 and kept on any other failure, so a later run retries it.
It ends with [ws-os-purge] workspace=… indices deleted=… failed=… ownership deleted=… kept=….
Operator routes and the orphan janitor
Section titled “Operator routes and the orphan janitor”Two token-guarded routes clean up what predates this change. Both live on
WorkspaceAdminController under /internal/admin, take header X-Logrite-Admin-Token (backend
env LOGRITE_ADMIN_TOKEN; unset → 403 admin endpoint disabled, wrong → 403 invalid admin token,
constant-time compare — the same guard as the licence admin routes) and the body
{"dryRun": true, "workspaceIds": [...]}. dryRun defaults to true; a real run requires
workspaceIds (400 otherwise; at most 500 ids), and only ids the dry run would list are acted on.
| Route | Dry run returns | Real run, per id |
|---|---|---|
POST /internal/admin/workspaces/reap-memberless | {dryRun, count, workspaces:[{workspaceId, name, createdAt, projects, projectGroups, subscriptionStatus}]} — memberless workspaces with no claimable invitation | reaped, or skipped with not_a_candidate, not_found, has_members, claimable_invitation, active_subscription:<STATUS> (ACTIVE / PAST_DUE; TRIALING is reaped) or error: … |
POST /internal/admin/opensearch/orphans | {dryRun, count, workspaces:[{workspaceId, indices:[{name, docs, bytes}], ownershipRows:{opensearchObjects, savedObjects, notificationConfigs}}]} — numeric segments in OpenSearch or the ownership tables with no row in workspaces | purged with indicesDeleted, indicesFailed, ownershipRowsDeleted, ownershipRowsKept, or skipped with not_an_orphan or the purger’s refusal reason |
The reaper re-checks every condition inside its own transaction, writes the governance audit
event workspace.reaped before calling deleteWorkspace, and a failure is reported in the
result rather than thrown. Each orphan purge is audited as opensearch.orphans.purged.
OpenSearchOrphanJanitor runs the orphan purge on a schedule when
LOGRITE_OPENSEARCH_ORPHAN_JANITOR_ENABLED=true (default false), at
LOGRITE_OPENSEARCH_ORPHAN_JANITOR_CRON (default 0 40 4 * * *, 04:40 daily), one workspace at
a time; a failing workspace does not stop the sweep.