Workspaces (per-user isolation & sharing)
By default EDDI is a single shared authoring workspace: every holder of eddi-editor sees, edits and deletes every agent, workflow, rule set and LLM config in the deployment. That is the right shape for a single team and the wrong shape for a deployment where several people or several teams build independently.
Turning workspaces on scopes configuration resources to the user or team that created them, and adds explicit sharing on top.
Conversations, user memories, attachments, HITL approvals and OAuth grants were already per-user and are unaffected by this feature. See Security.
Enabling it
Two switches, and they mean different things.
authorization.enabled
tracks quarkus.oidc.tenant-enabled
Authentication and role checks. Ownership is recorded whenever this is on.
eddi.workspaces.enabled
false
Whether ownership is enforced — listings filtered, reads and writes checked.
eddi.workspaces.groups-claim
groups
JWT claim carrying Keycloak group membership, which becomes team spaces.
eddi.workspaces.legacy-visibility
shared
What happens to resources created before ownership was recorded: shared or admin-only.
eddi.workspaces.default-space
(empty)
Empty = new resources land in the creator's personal space. Set to a group name for a team-first deployment.
Recording and enforcing are deliberately separate. Deploy the release, let attribution accumulate, confirm in the Manager that agents show the owners you expect, and only then set eddi.workspaces.enabled=true. Enforcing against data that was never stamped is what would hide people's own work from them.
eddi.workspaces.enabled=true with authorization.enabled=false does nothing and says so at boot: with no authenticated principal there is nothing to scope resources to.
Keycloak
Team spaces come from group membership, so the token has to carry it. The shipped realm (keycloak/eddi-realm.json) already includes a oidc-group-membership-mapper named groups on both clients, and a sample /engineering group. For an existing realm, add the mapper by hand:
Clients → eddi-backend → Client scopes → dedicated → Add mapper → Group Membership
Token Claim Name
groups, Full group path on, add to access token and ID token.
Without the mapper every user simply has a personal space and no teams. That is a correct answer, not a failure.
Group nesting is literal: a member of /engineering/backend gets the team:engineering/backend space, not team:engineering — Keycloak's membership claim lists the groups a user is actually in, and EDDI does not invent ancestry. Share with the parent team explicitly if that is what you mean.
Roles stay what they were — eddi-admin, eddi-editor, eddi-user, eddi-viewer, eddi-approver. Roles say what you may do; spaces say what you may see. Do not mint per-team roles: they do not compose, and they cannot express a one-off share.
The model
Spaces
Every resource is filed in exactly one space:
Personal —
user:<principal>, one member.Team —
team:<group path>, everyone in that Keycloak group.
A resource in a team space is visible and editable by the whole team. Deleting and re-sharing stay with whoever created it.
Visibility
private
The owner, and explicit grants only.
space
Everyone whose spaces include the resource's space. Default for new resources.
published
Everyone with access to the deployment, including anonymous callers on the public production chat endpoints.
Access levels
USE
Start conversations with the deployed agent; see its name and description.
Read the configuration, its workflows, tools or vault references.
VIEW
Read the resource and the config graph beneath it; export a copy.
Modify or deploy.
EDIT
Update and deploy.
Delete, or change who else has access.
OWN
Everything, including delete and re-share.
—
USE and VIEW are separate because letting a colleague talk to an agent is a different act from letting them read how it was built — and the first is by far the more common request.
Sharing
One endpoint family covers every resource type, keyed by resource id.
GET
/descriptorstore/descriptors/{id}/shares
Owner, space, visibility, grants, and the caller's effective level.
POST
/descriptorstore/descriptors/{id}/shares
Grant subject (user:… or team:…) a level.
DELETE
/descriptorstore/descriptors/{id}/shares
Revoke a subject's grant.
PUT
/descriptorstore/descriptors/{id}/shares/visibility
Set private / space / published.
PUT
/descriptorstore/descriptors/{id}/shares/owner
Transfer ownership. Administrators only.
Sharing cascades by default. An agent is a thin document pointing at workflows, which point at rule sets, LLM configs, output sets and api calls. A share that stopped at the agent would hand the recipient a name and a list of URIs they cannot resolve, so cascade=true (the default) walks the graph and applies the same change to everything beneath it. Pass cascade=false to touch exactly the one document.
Two things it deliberately will not do:
It will not pass on access you were lent. A referenced resource you can read but do not own is left alone and returned in the response's
skippedlist.It will not share more than 500 resources from one root. A cyclic or generated config cannot turn one share into unbounded write amplification; the cut-off is logged.
Asking what applies to you
A client cannot work out whether workspaces are enforced by looking at the data. Ownership is recorded whenever authentication is on — deliberately, so attribution accumulates before you flip enforcement — and a deployment with the feature off returns descriptors that look exactly like one where everything predates ownership. So the server says.
principal is the value stamped as ownerId — compare against it, not against a display name from the token; the two need not match. Space ids are opaque: they carry escaping a client must not re-derive, and one built differently selects a workspace matching nothing rather than failing. label is the decoded form, for display only.
It answers only for the caller, and takes no principal parameter, so it cannot be used to enumerate somebody else's group membership.
Listings accept the ids it returns:
space is a narrowing only — asking for a space you cannot reach returns nothing rather than granting it, and it narrows an administrator's view too. It is a query parameter rather than a client-side filter because page 2 of "everything" is not page 2 of "this space".
What a listing tells a client it may do
Every descriptor a listing returns carries callerLevel — USE, VIEW, EDIT or OWN — describing what the caller who asked may do with that resource.
It is per-request, not per-resource: the same document serialises differently for two people. A client needs it because nothing else in the payload answers the question — the grant list is disclosed to the owner only, so a recipient otherwise cannot tell an agent they may edit from one they may only talk to, and the alternative is offering every action and letting the server refuse.
Three properties are worth knowing:
Absent when enforcement is off. Everyone may do everything then, so a level would be true and useless. Omitting it keeps a listing byte-identical to a deployment that has never heard of workspaces.
Never stored. A value stamped for one caller would be wrong for every other, so the persistence mapper drops it — not by convention, but by a registered Jackson mix-in, because several paths read a descriptor and write it back.
Never accepted. It is read-only on the wire, so nothing a client sends can assert its own access level.
What changes for users when you enable it
Listings show only what the caller owns, shares a space with, has been granted, or that is published. Filtering happens in the query, so paging stays correct.
Reading, editing and deleting a resource by id is checked even when the id is guessed or pasted.
Deploy and undeploy require
EDIT. Previously any editor could take down any colleague's live agent.Starting a conversation requires
USE. An anonymous caller on the public production endpoints therefore reaches published agents only.Schedules, triggers and group membership are checked when they are authored: creating or re-pointing one at an agent requires
USEon that agent. The fire (or the group's member turn) runs system-initiated and is deliberately not re-checked — the vet happens where the human is.The OpenAI-compatible
/v1API serves published agents only under enforcement, and lists only those. It authenticates with one shared key and takes the user id from a header, so there is no verified principal to scope to — honouring that self-asserted id would let a single leaked key reach any user's private agents.Exporting an agent requires
VIEWon it. Export reads the agent and every configuration it references, so leaving it ungated would have been a complete read of any agent by id.Duplicating a resource produces a copy owned by whoever duplicated it, in their space, at
spacevisibility — never a copy filed under the original owner's name.Importing a ZIP files everything under the importing user. A ZIP's descriptors are treated as untrusted for ownership: an archive cannot decide who owns a resource on your deployment, publish it, or grant access to somebody. Exported ZIPs likewise carry no owner, space, visibility or grants, so they do not disclose your principal and team names to whoever receives them.
That "starting a conversation" line is the change most likely to surprise: an agent created after enforcement is on is not public until somebody publishes it. Agents that predate ownership stay reachable under legacy-visibility=shared, so switching the feature on does not silently take an existing public bot offline.
The Platform Operator
The Operator works with workspaces, but two things must be true.
1. It must authenticate as the chatting user. Set its auth mode to caller-identity, so its tools send Bearer ${caller:token}. Then every action it takes runs with the real user's permissions: it lists what they can see, edits what they may edit, and anything it creates is owned by them. In none mode its tool calls carry no credentials and get 401 as soon as OIDC is on — before workspaces enter the picture at all.
2. The Operator agent itself must be reachable by everyone who uses it. It is provisioned by whoever activates it, so under enforcement it lands in that person's space and every other user gets 403 when they open the drawer. It is a platform tool, not a personal one — publish it once after activation:
Publishing makes its configuration readable by everyone who can reach the API — its system prompt and tool definitions, though not its vault-referenced credentials. If that is more than you want, share it with a team at USE instead: POST .../shares?subject=team:staff&level=USE&cascade=true.
The same applies to any agent meant to serve a whole deployment rather than one person.
Resources with no descriptor at all
A few creation paths produce no descriptor — most notably the setup API, which reaches the stores over an internal loopback call with no credentials. Those resources have no recorded owner, and EDDI cannot invent one.
They stay readable and usable under legacy-visibility=shared, so nothing breaks. They are not editable, deletable, deployable or shareable by non-admins: an absent record must not grant authority. Each refusal is logged at WARN naming the resource, so the gap is findable. Assign an owner with the ownership-transfer endpoint to close it.
MCP inherits it where it shares the beans. Tools that hold an injected IRest*Store — the agent store in McpConversationTools, the group store in McpGroupTools, agent administration in McpAdminTools — call the same objects in-process and are checked identically. Tools that resolve a store through IRestInterfaceFactory make a loopback HTTP call instead, so the endpoint's own checks apply to a request that carries no credentials. That is a pre-existing limitation of EDDI's internal loopback calls (they already fail under authorization.enabled=true), not something workspaces introduce.
The engine deliberately does not inherit it. A conversation turn runs under the chatting user's identity, and requiring them to own the agent's configuration would break every shared agent.
Upgrading an existing deployment
Deploy the release.
WorkspaceAccessIndexMigrationruns once at startup and stamps every existing descriptor as legacy. It does not invent owners — attribution cannot be reconstructed after the fact, and a confident wrong answer is worse than an honest "unowned".Leave
eddi.workspaces.enabled=false. New resources are attributed to their creators from this point on.Check the owners look right.
Set
eddi.workspaces.enabled=true.Optionally move to
legacy-visibility=admin-onlyonce the pre-existing resources have been assigned owners with the ownership-transfer endpoint.
Rolling back is setting the flag to false. The recorded ownership is inert while enforcement is off.
Last updated
Was this helpful?