From 63830b7bd6713dd25bf33274567e7a2435dec48b Mon Sep 17 00:00:00 2001 From: ivanantonijevic Date: Sun, 11 Oct 2026 08:51:46 +0200 Subject: [PATCH] Workspaces (multi-tenancy): design doc The 1.5.0 preparation for feature 5 of #178 is this document; the schema does not change yet. Agent names are global and double as the ADK app name, so the doc keeps them unique across the installation. The real work is scoping every dashboard route by workspace, with a test that walks all routes against two workspaces. It proposes workspaces and members, workspace_id on projects and on log rows, a default workspace for existing data, and lists provider keys per workspace as the question that decides the rest. --- documents/design/workspaces.md | 193 +++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 documents/design/workspaces.md diff --git a/documents/design/workspaces.md b/documents/design/workspaces.md new file mode 100644 index 0000000..6591c42 --- /dev/null +++ b/documents/design/workspaces.md @@ -0,0 +1,193 @@ +# Design: workspaces (multi-tenancy) + +Status: proposal. Tracking: #178, feature 5, a 2.0 theme. For 1.5.0 this document is +the preparation. Nothing in the schema changes yet. + +## Problem + +One MATE installation serves one organisation. Projects group agents, but +everything else is shared across the installation: + +- users and their roles; +- who is an administrator; +- budgets; +- the audit log; +- settings and provider keys. + +An agency hosting agents for several clients, or a company with departments that +must not see each other's agents, conversations and costs, would need separate +installations. + +A workspace is an organisation inside one installation. It has its own members, +agents, keys, budgets and audit log. A person can belong to several workspaces. + +## What the code does today + +| Area | Today | Where | +|---|---|---| +| Users | one global `users` table; `user_id` unique across the installation | `User` | +| Roles | a JSON list on the user (`admin`, `user`, `widget`, custom); global | `User.roles` | +| Administrator | yes or no, for the whole installation. Basic auth, bearer tokens, `AUTH_USERNAME` and the `admin` role are all admins | `server/auth.py` `get_user_role` | +| Dashboard API | every `/dashboard/api/` route is admin-only through `DashboardAuthzMiddleware`, unless allowlisted | `docs/dev/auth-and-access.md` | +| Projects | group agents; no members; `name` unique | `Project` | +| Owned by a project (`project_id`) | agents, memory blocks and their versions, widget keys, channel integrations, triggers, file search stores, response feedback | | +| Agents | `name` unique across the installation, and the same string is the ADK app name, the `/apps/{name}` path, the session key, `token_usage_logs.agent_name` and the `scope_id` of agent budgets | `AgentConfig.name` | +| Agent access | the user's global roles against the agent's `allowed_for_roles` | RBAC callback | +| Logs | `token_usage_logs`, `guardrail_logs`, `agent_responses` and `audit_logs` have no project; usage per project is found through agent → project | | +| Budgets and alerts | scope `user`, `agent`, `project` or global | `RateLimitConfig`, `AlertRule` | +| Installation-wide | model prices, system settings, migrations, provider keys (environment variables), personal access tokens (per user) | | + +## The two hard parts + +The schema is the easy part. Two things make this a 2.0 feature. + +### 1. Agent names are global + +An agent's name is also its ADK app name. That name is in URLs, in the session +store's keys, in every token usage row, and in budget scopes. Two workspaces can't +both have an agent called `support`. + +- **Keep names unique across the installation (recommended for 2.0).** + - Creating or importing an agent refuses a name another workspace uses, and says + so without naming that workspace. + - Templates already prefix their agent names (`template_prefix`), so imports + rarely clash. + - Nothing that uses the name has to change. +- **Give agents an internal name, such as `w12__support`, and show `support`.** + - This would mean changing the app name everywhere: URLs, sessions, logs, budgets, + A2A and MCP endpoints, standalone builds, and existing sessions. + - It's a lot of risk for a cosmetic gain. It can come later if clashes become a + real problem. + +### 2. Every admin route assumes one organisation + +There are 44 `_get_is_admin` checks in `dashboard_server.py` alone, and the +middleware makes every dashboard API admin-only. With workspaces: + +- "admin" becomes "admin of *this* workspace"; +- every query has to filter by the current workspace. + +A single query that forgets the filter leaks another organisation's data. The work +is in going through the routes, not in the tables, and it needs a test that catches +a missed filter (see [Testing](#testing)). + +## Proposed data model + +**New tables:** + +```text +workspaces + id, name (unique), slug (unique), created_at, updated_at + +workspace_members + workspace_id → workspaces.id (cascade) + user_id → users.user_id (cascade) + role owner | admin | member + created_at + unique (workspace_id, user_id) +``` + +**Changed tables:** + +| Table | Change | +|---|---| +| `projects` | `workspace_id` NOT NULL → `workspaces.id`; `name` unique per workspace instead of globally | +| everything with `project_id` | nothing: the workspace comes through the project | +| `token_usage_logs`, `guardrail_logs`, `agent_responses`, `audit_logs` | `workspace_id`, written when the row is written; nullable for installation-level events | +| `rate_limit_config`, `alert_rules` | a new scope, `workspace` | +| `personal_access_tokens` | `workspace_id`: a token acts in one workspace | + +**Why copy the workspace onto log rows, instead of joining through the agent?** +- Agents are renamed, deleted and moved between projects. A cost or an audit entry + must stay where it was recorded. +- Usage queries over large tables stay single-table. + +**What stays installation-wide:** +- users (one identity across workspaces); +- migrations, server control and system settings; +- model prices. Per-workspace prices are an open question. + +The installation's own administrator becomes the **platform administrator**: the one +who sees every workspace and manages these settings. + +**Default workspace.** The migration creates workspace 1, "Default", and: +- moves every project into it; +- makes every user with the `admin` role its `owner`, and every other user a + `member`; +- sets `workspace_id = 1` on existing log rows. + +An installation with one workspace looks the same as today, and the dashboard shows +no workspace switcher until there is a second one. + +## Access with workspaces + +- **Current workspace.** The dashboard session holds the current workspace, chosen + in a switcher. A personal access token carries its own. A request for a workspace + the user isn't a member of is refused. +- **The dashboard API.** The middleware's admin check becomes "owner or admin of the + current workspace". Routes for installation settings (migrations, server control, + system settings, model prices) stay platform-admin only. +- **One place builds the filter.** A dependency gives each route its + `(workspace_id, role)`. Routes take the filter from there, never from a query + parameter. +- **Agents.** The RBAC callback first checks that the caller is a member of the + agent's workspace, then `allowed_for_roles`, as today. Widget keys belong to a + project and so to a workspace. Widget visitors are not members; their access is + the key, as today. +- **Audit log.** Each workspace sees and exports its own entries. Platform admins + see all of them. Retention stays installation-wide. + +## What 1.5.0 does + +Only this document. The schema change touches a dozen tables on three databases, +and without settled answers to the open questions it's easy to add a column we +would later redo. + +**One cheap step pays off even without workspaces:** writing `project_id` onto +`token_usage_logs` and `audit_logs` when rows are written. Usage per project would +then not depend on the agent still existing in that project, and the 2.0 backfill +would be exact. It isn't part of 1.5.0; it's a candidate for the first release after. + +## Open questions + +1. **Provider keys per workspace.** + - Today `GOOGLE_API_KEY`, `OPENROUTER_API_KEY` and the rest are environment + variables, so every workspace spends on the same accounts. + - A hosting setup usually needs each client's own keys, stored encrypted per + workspace and passed to the model per call. + - This is the largest piece after scoping, and it decides whether workspaces are + for departments, where shared keys are fine, or for clients. +2. **Model prices per workspace**, for negotiated rates. They stay installation-wide + until someone needs this. +3. **Moving a project to another workspace.** Allowed for platform admins? Its logs + keep the old `workspace_id`, which is the point of copying it. +4. **Sign-up.** Do workspaces get created only by platform admins, or by anyone who + signs in (self-service SaaS)? The first version is platform admins only. +5. **SSO.** Should an email domain map to a workspace (`OAUTH_ALLOWED_DOMAINS` per + workspace), so new users land in the right one? + +## Phases (2.0) + +1. **Schema.** The tables, the default workspace, the backfill, and `workspace_id` + written on new log rows. No behaviour change. +2. **Scoping.** + - the workspace context and the switcher; + - the middleware's per-workspace admin; + - every dashboard route and the RBAC callback filtered by workspace; + - the cross-workspace test. +3. **Management.** Workspace and member pages, invitations, and per-workspace + budgets and alerts. +4. **Provider keys per workspace**, if the answer to open question 1 is "clients". + +## Testing + +- **A cross-workspace test**, over every route. + - Fixture: two workspaces, each with a project, an agent, a widget key, usage, + audit entries, a budget and a member. + - For every `GET` route under `/dashboard/api/`, list them from the app's router + so new routes are covered automatically. Call each as workspace A's admin, and + assert that no id or name from workspace B appears in the response. + - For every write route, assert that an id from workspace B gets 404. +- **The migration on all three databases:** the backfill puts every existing row in + the default workspace, and an installation with one workspace behaves as before. +- **The RBAC callback:** a member of A can't run an agent of B, whatever their roles.