Organizations and projects#
Two levels, each with a clear job: the organization is the tenant boundary and the governance ceiling, and projects are the blast radius. People and API tokens reach a project through a role on it.
Create the hierarchy#
curl -fsS -X POST \
-H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
-H "Content-Type: application/json" \
https://agents.example.com/v1/orgs \
-d '{"name": "acme", "displayName": "Acme Corporation"}'
curl -fsS -X POST \
-H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
-H "Content-Type: application/json" \
https://agents.example.com/v1/orgs/$ORG_ID/projects \
-d '{"name": "production", "displayName": "Production"}'
Set up an org called acme ("Acme Corporation") with a production
project.
# Organization — the tenant boundary
nexusctl org create --name acme --display-name "Acme Corporation"
# Project — where sandboxes and connections live
nexusctl project create --org acme --name production --display-name "Production"
Slugs are lowercase letters, digits, and dashes. They are what you type in every later command, so pick names that will still make sense in a year.
Installation owners#
By default, anyone who can sign in can create an organization. On an installation behind a corporate identity provider that means every employee, which is rarely what you want.
config.ownerEmails names the people who run the installation — typically an IT department — by email at deploy time:
helm upgrade lens-agents oci://ghcr.io/lensapp/lens-agents --reset-then-reuse-values \
--set 'config.ownerEmails={ops@acme.com,admin@acme.com}'
| Value | Default | Purpose |
|---|---|---|
config.ownerEmails |
[] |
Installation owners, by email. Empty names no owners and keeps org creation open. |
Matching is on the exact address and ignores case. ops@acme.com admits that address and not the rest of acme.com.
What naming owners changes#
Two things, both of which are open on an installation that names nobody.
Only owners may create an organization. Everybody else joins one by invitation. A non-owner no longer sees the Create Organization action in the admin UI, and the API refuses the call with 403.
Only people with something to do here may sign in at all. A signed-in person is admitted if any one of these holds:
- they belong to an organization,
- they may create one — an owner, or anybody at all when no owners are named,
- an invitation is waiting for their email address.
Everybody else is refused and no account is stored for them. This is the rule that matters behind a corporate IdP: without it, authenticating the whole company means the whole company collects an account.
The refusal is 403 and never 401, because the token is valid and the person is who they say they are. A 401 would send the client back to the IdP for a fresh token and into a login loop. The admin UI shows a "No access to this installation" screen with a sign-out button; the MCP endpoint answers a JSON-RPC error. Neither offers a login challenge.
An owner is always admitted, so naming yourself keeps you in.
Your IdP must put a verified email claim in the access token
Owner matching reads the email from the token the client sends, which is the access token — not the id_token. Some providers, Microsoft Entra ID among them, omit email from access tokens until you configure it. Then config.ownerEmails matches nobody and every person who belongs to no organization is refused, owners included. Verify the claim is present before you set the value.
The claim must also be verified by the IdP. Where a user can set their own email address without proof, that user can claim an owner address and become an owner. An owner address is refused when the token says email_verified: false, and passes the direct-access gate without the role only when it says email_verified: true. Keycloak users an administrator creates start unverified, so verify the owners' addresses in the IdP first.
What owners are not#
Owner is not an org or project role, and there is no owner row in the database — owners are a property of the deployment rather than of any organization.
It grants nothing inside an organization. An owner who is not a member of an org still cannot see it, change it, or delete it. Permissions inside an org come from org and project roles, exactly as they do for everyone else.
Organizations#
The tenant boundary. API tokens, spending limits, the audit trail, org-scoped policies, and billing all live here.
Invite people#
curl -fsS -X POST \
-H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
-H "Content-Type: application/json" \
https://agents.example.com/v1/orgs/$ORG_ID/invitations \
-d '{"email": "alex@example.com", "role": "member"}'
Invite alex@example.com to the acme org as a member.
nexusctl invitation create --org acme --email alex@example.com --role member
nexusctl invitation list --org acme
Invitees see and act on their own invitations:
nexusctl invitation list-mine
nexusctl invitation accept <invitation-uuid>
Revoke a pending invitation with nexusctl invitation revoke <invitation-uuid> --org acme.
Org policies are a ceiling#
This is the most important thing on this page.
An org-scoped policy is not merely a default that projects inherit. It is an upper bound. When the platform resolves a sandbox's effective policy, anything a project binding grants beyond what the org policy permits is clipped.
graph TD
org[Org policy<br><i>the ceiling</i>] --> merge[Effective policy]
proj[Project policies and bindings] --> merge
sb[Sandbox-attached policies] --> merge
merge --> clip{Clip to org ceiling}
clip --> eff[What the agent can actually do]
That gives a platform team a real control: write one org policy naming the domains, connectors, and inference providers the organization permits at all, and no project — however it is configured, by whoever configures it — can exceed it.
Clipping is silent, so audit it:
nexusctl policy-binding list-drift --org acme
Every drift entry is a project admin who believes they granted something the organization does not allow. Review it on a schedule. See Policy bindings.
Projects#
Where the work happens. Sandboxes, credentials, connectors, clusters, AWS connections, and project policies each belong to exactly one project.
A project is the blast radius. An agent in staging cannot see production's credentials or connectors, whatever its policy says — those resources are not in its scope to name.
nexusctl project list --org acme
nexusctl project describe production --org acme
describe shows a project's clusters, policies, AWS connections, and resource counts in one view.
How to split projects#
Use the boundary that matches your real blast radius, usually one of:
- By environment —
production,staging. Different credentials, different limits, different people. - By ownership — each group of engineers owns its project end to end.
- By risk — separate the agents that may write from those that may only read.
Prefer more projects to fewer. A project is cheap; a credential shared across two blast radii is not.
Project members#
A user or an API token reaches a project through a role on it. A user must be a member of the project's organization, and an API token must belong to it. Org admins hold ADMIN on every project in their organization without a role of their own.
curl -fsS -X PUT \
-H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
-H "Content-Type: application/json" \
https://agents.example.com/v1/projects/$PROJECT_ID/members/users/$USER_ID \
-d '{"role": "ADMIN"}'
curl -fsS -X PUT \
-H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
-H "Content-Type: application/json" \
https://agents.example.com/v1/projects/$PROJECT_ID/members/api-tokens/$TOKEN_ID \
-d '{"role": "MEMBER"}'
curl -fsS -H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
https://agents.example.com/v1/projects/$PROJECT_ID/members
$USER_ID is the user's OIDC subject.
Give alex@example.com ADMIN on production and MEMBER on staging.
Give the ci-deploy token MEMBER on production.
nexusctl project member set production --user alex@example.com --role ADMIN
nexusctl project member set staging --user alex@example.com --role MEMBER
nexusctl project member set production --api-token ci-deploy --role MEMBER
nexusctl project member list production
--user takes an email or a user id, and --api-token a token name or id. Running set again on an existing member changes the role. The admin UI shows the same list on each project's Members page.
| Role | Can |
|---|---|
MEMBER |
Use the project — list and inspect resources, run work within policy. |
ADMIN |
Everything a member can, plus manage project resources and exec into sandboxes. |
sandbox exec requires project ADMIN. Getting a shell inside a running agent is an administrative action, not an everyday one.
Only org admins give, change, and remove project roles. An API token is never an org admin, so it cannot change who reaches a project. A change made from a sandbox session is refused with 403, even when the sandbox's creator is an org admin. Every change is recorded in the audit trail as a project-member resource.
nexusctl project member remove staging --user alex@example.com
nexusctl project member remove production --api-token ci-deploy
Removing a person from the organization removes their role on every project in it. Offboarding is one removal, not an audit of every project.
Example setup#
| Resource | Example |
|---|---|
| Org policy | org-ceiling — every domain, connector, and provider the company permits at all |
| Projects | production, staging |
| Project members | Platform engineers ADMIN on both, application engineers MEMBER on staging, ci-deploy token MEMBER on production |
| Org spending limit | Monthly, matching the agreed budget |
| Drift review | policy-binding list-drift --org acme, monthly |
Related#
- Identity — the principals in this hierarchy
- Policies — org and project scope
- Policy bindings — the ceiling and drift in detail
- SSO — authenticating members against your directory