People, teams, roles and access
The API calls behind people, teams, roles and access.
On this page
- The order of calls
- Two fields need a permission to read
GET /api/access— everyone, every team, every roleGET /api/access/inheritance— a page of what can pass a role downGET /api/access/historyGET /api/access/exportPOST /api/access/previewPOST /api/access/applyPOST /api/users— add one personPATCH /api/users/{uid}— change one thing about one personDELETE /api/users/{uid}GET /api/users/{uid}— one personPOST /api/groups— add a teamPATCH /api/groups/{uid}DELETE /api/groups/{uid}GET /api/groups/{uid}— one teamPOST /api/groups/{uid}/members— put somebody in a teamDELETE /api/groups/{uid}/members/{username}POST /api/rolesPATCH /api/roles/{uid}DELETE /api/roles/{uid}PUT /api/roles/{uid}/permissions— one tick in the gridPUT /api/roles/{uid}/levels— where a role may be assignedPOST /api/responsibilities— make somebody responsibleDELETE /api/responsibilities/{ruid}PUT /api/assets/{uid}/inheritance— one asset's modePUT /api/settings/inheritance— the defaultGET /api/workflow-controls— how far workflows reachPUT /api/workflow-controls- The People tab's four panels
Part of the HTTP API reference.
Routes:
| Route | Draw function |
|---|---|
#/access/<tab> |
drawAccess(tab) |
#/person/<uuid> |
drawPerson(uid) |
#/team/<uuid> |
drawTeam(uid) |
Tabs: People, Teams, Roles, Permissions, Assignments, Inheritance, History — plus the panels on People for maintenance, sign-up, view-as and workflow controls.
The order of calls
Every tab:
GET /api/accessGET /api/people
Then, per tab or panel:
| Tab / panel | Call |
|---|---|
| Inheritance | GET /api/access/inheritance?… |
| History | GET /api/access/history |
| the workbook panel | GET /api/access/export, POST /api/access/preview, POST /api/access/apply |
| People → maintenance | GET /api/maintenance, PUT /api/maintenance |
| People → workflow reach | GET /api/workflow-controls, PUT /api/workflow-controls |
| People → sign-up | GET /api/signup-settings, PUT /api/signup-settings |
| People → view-as | PUT /api/settings/view-as, then GET /api/me |
| People → invite | POST /api/invites |
Every write below goes through _admin or _may. Reading the access model on
the screen needs a session and nothing more; changing it needs ACCESS_MANAGE,
by every path including the workbook. Downloading the workbook — the whole
model in one file, and the shape an upload is read from — needs ACCESS_VIEW
as well as PERSON_IDENTIFY since v91, and the refusal names whichever of the
two is missing. The code carried a comment saying the opposite —
"there is no login, so no permission can block anything" — which was true
before v21 and wrong for the fifty-five versions after it, and this endpoint
answered "enforced": false to match. Both have been corrected.
Two fields need a permission to read
PERSON_IDENTIFY (See usernames and email addresses, migration 0083) decides
whether a reader is served other people's username and email. It
affects GET /api/access, GET /api/users/{uid} and GET /api/access/export,
and nothing else.
Three rules, and the first is the one to write a client against:
- The fields are ABSENT, not null. A key that is not there cannot be
mistaken for a value that is not set — and on this screen an empty
emailalready means "nobody has recorded one", with an editable field and an Invite button next to it. A client that readsemailand finds the key missing has been told it was not served one; a client that foundnullwould have been told something false. - Your own row is always served, both fields, whether you hold the permission or not.
- The workbook refuses rather than redacts.
GET /api/access/exportis403without the permission, because usernames are the keys its Users, Group members and Assignments sheets match on and an upload reads a missing row as a removal. A redacted copy is not a safer file — it is a file that proposes creating everybody again.
GET /api/access carries "may_identify": true|false so a client can tell
not served to you from not recorded; GET /api/users/{uid} carries
"identity_shown", which is true on the reader's own page either way.
Asked of whoever the request is answered as — the person being viewed as, when a session is viewing as somebody else, like every other read on this screen.
GET /api/people is not affected and still carries every active
username to any session: it is the actor picker, the view-as list and every
people picker in the product. That edge is deliberate and verify asserts it,
so that closing it later is a decision somebody takes rather than a line that
drifts.
GET /api/access — everyone, every team, every role
No parameters. A session is enough for the whole access model except the two identifying fields above.
Response
{
"users": [ // ORDER BY lower(display_name) — INACTIVE INCLUDED
{"uid": "…", "username": "r.iyer", "display_name": "Rohan Iyer",
"email": "…", // username and email: BOTH ABSENT without
// PERSON_IDENTIFY, except on your own row
"is_active": true, "note": null,
"can_sign_in": true, // password_hash IS NOT NULL
"time_zone": "Europe/London", // what their browser last reported; read-only
"invited": 1, // live, unused invites
"holds": 12, // responsibilities, anywhere
"teams": 2}
],
"groups": [ // ORDER BY lower(name)
{"uid": "…", "code": "DATA_GOV", "name": "Data Governance",
"description": "…", "is_active": true, "members": 4, "holds": 3}
],
"roles": [ // ORDER BY display_order, code
{"uid": "…", "code": "OWNER", "name": "Owner",
"scope": "resource", // "global" | "resource"
"description": "…", "is_active": true, "holds": 41,
"permissions": ["ASSET_EDIT", …], // in permission display order
"levels": ["DATA_DOMAIN", "TABLE"]} // which types it may be assigned on; [] means anywhere
],
"permissions": [ // ORDER BY display_order, code
{"code": "ASSET_EDIT", "name": "Edit assets",
"category": "Assets", "description": "…"}
],
"assignments": [ // every assignment AS RECORDED — direct only, no inheritance
{"uid": "…", "role_code": "OWNER", "role": "Owner", "scope": "resource",
"holder": "A. Khan",
"holder_key": "a.khan", // a username, or a team code when kind is
// "team". Absent for a person without
// PERSON_IDENTIFY; team codes always served
"kind": "person", // person | team
"holder_uid": "…",
"asset_uid": "…", "asset": "Customer", "type_name": "Data Domain",
"responsibility_mode": "inherit"} // the asset's own mode
],
"inheritance_total": 500, // CAPPED at INHERITANCE_CEILING
"inheritance_capped": true, // true when there are more than 500
"last_changed": "…", // max(at) over ACCESS_TABLES in change_log
"default_inheritance": "inherit",
"may_identify": true, // were the two identifying fields served at all
"enforced": true
}
assignments is direct only on purpose: "The inherited view belongs on the
asset, where the reader can see what it was inherited from; a flat list of every
inherited pair would be thousands of rows restating the same handful of
decisions."
Why the inheritance list is not here any more — the longest performance note on this screen, and the fault it describes is worth understanding before adding anything to this payload:
The list of everything that can pass a responsibility down used to be part of this payload, and every access tab fetches this payload — People, Teams, Roles, Permissions, Assignments, all of them. So opening Teams downloaded the whole inheritance list.
That list is
v_conferring_asset: every asset that contains anything or is owned by anything. On ten million assets that is about two hundred thousand rows, and for EACH ONE it ranowning_groups(a.id)— a recursive walk per row — to name the domains it belongs to. Two hundred thousand recursive walks.Measured: the request did not finish.
pg_stat_activityshowed that one statement still running at six minutes, and counting the view on its own did not finish either. Reported as "all are slow — /#/access/teams, /#/access/inheritance", which is exactly right: the tab that has nothing to do with inheritance was slow BECAUSE of it.
What stays is the badge's number, counted over the types that can confer at all
rather than over the estate, and capped at INHERITANCE_CEILING = 500 —
"a badge does not need to be exact to be useful, and this one is exact until
there is more of it than anybody will page through." An exact count costs 2.0s
on ten million and 11.6s uncapped; "saying 500+ is not a lie."
What "can confer" means (_CONFER_CTE) — two halves, and they
are not symmetrical:
An asset confers if it HOLDS something — it is the container end of a containment relation, the SOURCE — or if something is owned by it — it is the owner end of an ownership relation, the TARGET. Written with
sourceon both sides the domains disappear off the screen, which is what the first version of this did and what the check caught on its first run: the list began at "Order to Cash" where it should begin at "Customer".
The relation type ids are resolved once for the statement, in an InitPlan,
rather than per row: "Per row, through a function, a capped count of five
hundred cost 12s on ten million assets; as an InitPlan it is 50ms."
GET /api/access/inheritance — a page of what can pass a role down
Query parameters
| Name | Type | Default | Clamped |
|---|---|---|---|
type |
string | null |
stripped, upper-cased |
q |
string | null |
name ILIKE %q% OR external_key ILIKE %q% |
limit |
int | 200 (INHERITANCE_PER_PAGE) |
max(1, min(limit, 1000)) |
offset |
int | 0 |
max(0, offset) |
Docstring — the three things that make it a page instead of a hang:
- only the TYPES that can confer are candidates, read from the metamodel, so nine and a half million columns are never considered;
- "does this one confer" is an indexed lookup on
relation, with the relation type ids resolved once for the statement rather than per row — resolved per row, through a function, the same count took 12s;- the domains are resolved for the rows ON THE PAGE. Two hundred recursive walks is a page; two hundred thousand is a hang.
offsetrather than a keyset, because this is a screen somebody pages through a few pages of while filtering by type — the same shape as a run's lines, and paged the same way.
And the merge, which is the same trick /api/assets uses:
A page from each candidate type, merged —
ORDER BY t.display_order, a.namespans two tables, so with no type named the database sorted all two hundred thousand candidates to show two hundred. 17.8s that way; 0.5s when a type WAS named, which is what gave it away.
And one more, worth knowing if you touch the SQL: ct is joined outside
the lateral. "Inside, it is a cross join re-evaluated per candidate row and the
page took 35s; outside, 23ms. Same rows."
Response
{
"rows": [
{"uid": "…", "name": "SALES", "type_name": "Schema", "type_code": "SCHEMA",
"external_key": "PROD.SALES",
"mode": "", // the asset's own responsibility_mode, "" when unset
"is_domain": false, // is_grouping_type(asset_type_id)
"domains": ["Customer"]} // owning_groups, by name, for THIS PAGE only
],
"offset": 0, "limit": 200,
"more": true, // len(rows) == limit
"default_inheritance": "inherit"
}
more is len(rows) == limit, so on an exact multiple it is a false positive
for one extra request — the handler takes that over paying for a count.
GET /api/access/history
Query parameters: limit — int, default 60. Not clamped.
Response — the same shape as /api/metamodel/history:
{"events": [
{"at": "…", "actor": "superadmin",
"actor_name": "Superadmin", "actor_kind": "person",
"kind": "Assignment", // Person | Team | Team member | Role | Permission | Role level | Assignment
"verb": "added", "action": "insert",
"what": "A. Khan as Owner on Customer",
"fields": [ … ]} // only on an update
]}
what per table (label):
| Table | what |
|---|---|
responsibility |
"<who> as <role> on <asset>", or "… over the repository" |
role_permission |
"<Role> · <Permission>" |
role_level |
"<Role> · <Asset type>" |
group_member |
"<Person> in <Team>" |
| anything else | display_name, then name, then code, then "—" |
NOISE here is id, uid, created_at, updated_at, added_at,
display_order.
One performance note that is a lesson about bulk lookups:
The small tables are read whole because they ARE small — roles, teams, permissions, types, people: tens of rows each, and reading them whole is cheaper than working out which ones are wanted.
assetIS NOT ONE OF THOSE, and it was in this list.SELECT id, name FROM asseton ten million assets: 7.5s in the database and another 24s building a Python dictionary of ten million entries — to put a name on the handful of assets that appear in sixty history rows. Measured end to end at 38.6s for a screen that shows sixty lines.
Only the asset ids the rows actually mention are looked up now, and if they mention none, nothing is.
GET /api/access/export
No parameters. Needs PERSON_IDENTIFY — and refuses rather than redacting,
because the usernames in it are the keys an upload matches on. See Two fields
need a permission to read above.
xlsx, filename krisid-access-<YYYY-MM-DD>.xlsx, in the shape the upload
expects.
403 naming the permission otherwise. The screen does not render the download
link at all without it: this is an <a href>, so a refusal would navigate the
whole browser to a page of JSON — the one place in the application where a 403
is not caught and shown as a message.
POST /api/access/preview
multipart/form-data with file. No permission check. "Rolled back
either way."
400 with access_io.parse's ValueError when the file cannot be read.
Response: {"filename": …, **p.as_dict}.
POST /api/access/apply
multipart/form-data with file. No other parameters.
Permission: ACCESS_MANAGE — Sysadmin alone in the starter model —
checked before the file is read. The actor recorded is the person signed in.
It used to take an actor query parameter and no permission at all. Of the
three workbook applies this was the worst one to leave open: it writes people,
teams, roles, permissions, responsibility assignments and inheritance modes,
so anybody with a session could have granted themselves Sysadmin and had the
history name somebody else.
Response: {"filename": …, "applied": <bool>, **p.as_dict}. Committed only
when p.safe.
POST /api/users — add one person
Permission: ACCESS_MANAGE — "add somebody to the access model".
Body
| Field | Type | Required | Notes |
|---|---|---|---|
username |
string | yes | stripped, lower-cased |
display_name |
string | yes | stripped |
email |
string | no | stripped, null when empty |
note |
string | no | stripped, null when empty |
USERNAME_RE = ^[a-z0-9][a-z0-9._-]{1,60}$ — so 2 to 61
characters.
Refusals
| Condition | Status | Message |
|---|---|---|
| bad username | 400 | "A username is lower case, starts with a letter or a digit, and holds letters, digits, dots, dashes and underscores. It is what they sign in with and what History records, so it never changes afterwards — 'r.iyer' rather than 'Rohan Iyer'." |
| no display name | 400 | "Everybody needs a name to be shown by." |
| a bad email | 400 | "'<email>' is not an email address." |
| the username is taken | 409 | "<Name> already has that username. Two people cannot share one: it is what the whole access model points at." |
EMAIL_RE = ^[^@\s]+@[^@\s.]+\.[^@\s]+$.
Docstring: "They cannot sign in until somebody invites them." No password
is set, and the new row is given the VIEWER role over the whole repository —
"The floor everybody stands on, so a new person can read the repository the
moment they get in rather than seeing empty screens until somebody remembers to
grant it."
Response: {"uid": "<new uid>", "username": "r.iyer"}.
PATCH /api/users/{uid} — change one thing about one person
Permission: ACCESS_MANAGE — "change who is in the access model".
Docstring: "The access workbook is still the way to load fifty people at once. This is for the thing an administrator actually does between uploads — putting in an email address so an invite can be sent to it — and it was impossible, which made 'send them the link' a copy-and-paste job every time. One field per call, like every other edit in this application: each save is its own line in History, naming the field and both values."
PERSON_FIELDS: display_name, email, note,
is_active. username is not here on purpose — "it is what somebody
signs in with and what every change_log row records, so renaming it would
orphan a history — the same reason a code cannot be renamed in the
metamodel."
Refusals
| Condition | Status | Message |
|---|---|---|
| nothing recognised | 400 | "Nothing here can be changed. What can: display_name, email, is_active, note." |
| a bad email | 400 | "'<email>' is not an email address. It needs a name, an @ and a domain — nothing else is checked, because the only real test of an address is that a message arrives." |
| somebody else has that email | 409 | "<Name> already has that address. Two people sharing one is how an invite reaches the wrong person." |
| an empty display name | 400 | "Everybody needs a name to be shown by." |
| switching off your own account | 400 | "That would switch off the account you are signed in as. Somebody else with ACCESS_MANAGE can do it for you." |
| no such person | 404 | "no such person" |
Switching somebody off puts them out — "the access model saying 'gone' and the application still answering to them is the gap nobody notices" — every session for that account is revoked.
Response: the row as it now is, plus the count:
{"uid": "…", "username": "r.iyer", "display_name": "Rohan Iyer",
"email": "…", "note": null, "is_active": false, "sessions_ended": 2}
DELETE /api/users/{uid}
Permission: ACCESS_MANAGE — "remove somebody from the access model".
Docstring: "Take somebody out entirely — only where there is nothing to lose."
Refusals
| Condition | Status | Message |
|---|---|---|
| no such person | 404 | "no such person" |
| it is you | 400 | "You cannot remove the account you are signed in as." |
| they hold anything | 409 | "<Name> still holds <n> assignments. Removing them would take those decisions away with no record of who made them — hand them over first, or switch this person off instead, which keeps the history and stops the sign-in." |
Response: {"removed": "<uid>"}.
GET /api/users/{uid} — one person
A session, and PERSON_IDENTIFY for somebody else's username and email —
absent without it, always served on your own page. See Two fields need a
permission to read above.
Docstring: "One person: their teams, and everything they are responsible for."
Response
{
"uid": "…", "username": "r.iyer", "display_name": "Rohan Iyer",
"email": "…", // both ABSENT without PERSON_IDENTIFY, unless
// this is your own page
"identity_shown": true, // whether those two are in this payload
"is_active": true, "note": null,
"teams": [{"uid": "…", "code": "DATA_GOV", "name": "Data Governance"}],
"held": [ // DIRECT assignments only
{"role_code": "OWNER", "role": "Owner", "scope": "resource",
"asset_uid": "…", "asset": "Customer",
"asset_type": "DATA_DOMAIN", "type_name": "Data Domain"}
// a global role has asset_uid, asset, asset_type and type_name all null
],
"via_team": [ // what they also cover by being in a team
{"team": "Data Governance", "role": "Steward",
"asset_uid": "…", "asset": "Customer", "type_name": "Data Domain"}
]
}
via_team is kept separate: "'you are the steward of this' and 'your team is'
are different conversations."
POST /api/groups — add a team
Permission: ACCESS_MANAGE — "add a team".
Body
| Field | Required | Notes |
|---|---|---|
name |
yes | stripped |
code |
no | stripped, upper-cased, spaces → _; derived from the name when absent |
description |
no | stripped, null when empty |
CODE_RE = ^[A-Z][A-Z0-9_]{1,40}$ — 2 to 41 characters,
starting with a letter.
Refusals: 400 "A team needs a name."; 400 "'<code>' cannot be a team
code. Upper case, starts with a letter, letters digits and underscores — it is
the key the workbook matches on, so it never changes afterwards."; 409
"<Name> already uses the code <CODE>."
Response: {"uid": "<new uid>", "code": "DATA_GOV"}.
PATCH /api/groups/{uid}
Permission: ACCESS_MANAGE — "change a team".
Fields: name, description, is_active. The code cannot be changed:
400 "What can be changed here: description, is_active, name. The code is the
key the workbook matches on."
404 "no such team"; 400 "A team needs a name." for an empty name.
Response: {"uid": "<uid>", **<just the fields that were given>}.
DELETE /api/groups/{uid}
Permission: ACCESS_MANAGE — "remove a team".
Refusals: 404 "no such team"; 409 "<Name> still holds <n>
assignments. Take those away first, or switch the team off — everybody in it
would lose what it confers the moment this row went."
Response: {"removed": "<uid>", "members_dropped": 4}.
GET /api/groups/{uid} — one team
No permission check.
{"uid": "…", "code": "DATA_GOV", "name": "Data Governance",
"description": "…", "is_active": true,
"members": [{"uid": "…", "username": "r.iyer", "display_name": "Rohan Iyer"}],
"held": [ … ]} // the same `_HOLDINGS` shape as a person's `held`
404 "no such team".
POST /api/groups/{uid}/members — put somebody in a team
Permission: ACCESS_MANAGE — "change who is in a team".
Body: {"username": "r.iyer"} — stripped, lower-cased.
Refusals: 404 "no such team"; 404 "There is nobody called <username>
here."
Inserted ON CONFLICT DO NOTHING, so adding somebody twice is not an error.
Response: {"team": "Data Governance", "person": "Rohan Iyer", "added": true}
— added: false when they were already in it.
DELETE /api/groups/{uid}/members/{username}
Permission: ACCESS_MANAGE — "change who is in a team".
{username} is stripped and lower-cased by the handler.
Refusal: 404 "That person is not in that team."
Response: {"removed": "<username>"}.
POST /api/roles
Permission: ACCESS_MANAGE — "add a role".
Body
| Field | Required | Default | Notes |
|---|---|---|---|
name |
yes | — | stripped |
code |
no | from the name | upper-cased, spaces → _, must match CODE_RE |
scope |
no | "resource" |
"global" or "resource" |
description |
no | — | stripped, null when empty |
Refusals: 400 "A role needs a name."; 400 "'<code>' cannot be a role
code."; 409 "<Name> already uses the code <CODE>."; and for a bad scope:
400 "A role is held either over the whole repository ('global') or over one
asset ('resource'). Those are the only two, because they are the two
questions the permission check can ask."
display_order is set to one past the current maximum.
Response: {"uid": "<new uid>", "code": "OWNER"}.
PATCH /api/roles/{uid}
Permission: ACCESS_MANAGE — "change a role".
Fields: name, description, is_active, scope. The code cannot be
changed: 400 "What can be changed here: description, is_active, name, scope.
The code is the key everything else points at."
Refusals
| Condition | Status | Message |
|---|---|---|
| no such role | 404 | "no such role" |
| a scope that is neither | 400 | "A role is either global or resource." |
| changing the scope while it is held | 409 | "<CODE> is held <n> times already. Changing what it is held OVER would leave those assignments meaning something they were never made to mean — take them away first if this is really what you want." |
| an empty name | 400 | "A role needs a name." |
Response: {"uid": "<uid>", **<the fields that were given>}.
DELETE /api/roles/{uid}
Permission: ACCESS_MANAGE — "remove a role".
Refusals: 404 "no such role"; 409
"<Name> is held <n> times. Removing it would take away everything those people
may do, in one go and without saying whose. Take the assignments away first,
or switch the role off — an inactive role confers nothing and keeps its
history."
Response: {"removed": "<uid>"}.
PUT /api/roles/{uid}/permissions — one tick in the grid
Permission: ACCESS_MANAGE — "change what a role may do".
Body
| Field | Type | Notes |
|---|---|---|
code |
string | the permission code, stripped and upper-cased |
on |
bool | truthy means grant |
Docstring: "One tick in the grid. What a role may do, changed where it is read."
Refusals
| Condition | Status | Message |
|---|---|---|
| no such role | 404 | "no such role" |
| no such permission | 404 | "There is no permission called <CODE>." |
clearing the last ACCESS_MANAGE anybody holds |
409 | "That is the last role anybody holds that can administer access. Taking it away would leave this repository with no way to grant it back except the database." |
The one refusal worth having here: nobody may take away the last way into the access model. A grid where every ACCESS_MANAGE tick can be cleared is a grid with a button on it that locks the building.
The check is "is there any other active role, held by somebody, that
carries ACCESS_MANAGE".
Response: {"role": "OWNER", "permission": "ASSET_EDIT", "on": true}.
PUT /api/roles/{uid}/levels — where a role may be assigned
Permission: ACCESS_MANAGE — "change where a role may be assigned".
Body: {"type_code": "TABLE", "on": true} — the code is stripped and
upper-cased.
Docstring: "Where a role may be assigned. Nothing declared means anywhere."
Refusals: 404 "no such role"; 404 "There is no asset type called
<CODE>."
Response: {"role": "OWNER", "type": "TABLE", "on": true}.
POST /api/responsibilities — make somebody responsible
Permission — two different ones, by whether an asset was named:
| Scope | Permission |
|---|---|
asset_uid given |
RESPONSIBILITY_ASSIGN on that asset — "Over one asset, the asset's own owner may do this" |
no asset_uid |
ACCESS_MANAGE — "Over the whole repository there is no owner to ask, so it is the access model's" |
Body
| Field | Type | Default | Notes |
|---|---|---|---|
role_code |
string | — | upper-cased; must be an active role |
holder |
string | — | a username (lower-cased) or a team code (upper-cased) |
kind |
string | "person" |
"person" or anything else (a team) |
asset_uid |
uid | "" |
empty means over the whole repository |
Refusals
| Condition | Status | Message |
|---|---|---|
| no such active role | 400 | "There is no active role called <CODE>." |
a global role with an asset |
400 | "<Name> is held over the whole repository, so it takes no asset. Assign it with nothing in Over." |
a resource role with no asset |
400 | "<Name> is held over one asset. Say which." |
| no such active holder | 400 | "There is no active <kind> called '<holder>'." |
| they hold it already | 409 | "They hold that already." |
| the database refuses the level | 400/409 | via _refusals |
Docstring: "The asset page has had this for one asset since the beginning. This is the same write from the other end: the screen where somebody is working through who holds what, including the repository-wide roles that have no asset to be assigned on."
Response: {"uid": "<new responsibility uid>"}.
DELETE /api/responsibilities/{ruid}
Permission: the same split — RESPONSIBILITY_ASSIGN on the asset when the
assignment has one, else ACCESS_MANAGE ("take away a role held over the
whole repository").
Refusal: 404 "no such assignment".
Response: {"removed": "<ruid>", "role": "Owner", "holder": "A. Khan"}.
PUT /api/assets/{uid}/inheritance — one asset's mode
Permission: ACCESS_MANAGE — "change how responsibility is passed
down".
Body: {"mode": "inherit"}. Allowed: "inherit", "inherit_locked",
"none", or empty/missing/null meaning "fall back to the repository
default".
Refusal: 400
"An asset either passes its responsibilities down ('inherit'), passes them down
and stops anything below overriding them ('inherit_locked'), or keeps them to
itself ('none'). Empty means whatever the repository's default is."
Response: {"uid": "<uid>", "mode": "inherit"} — mode is null when
cleared.
PUT /api/settings/inheritance — the default
Permission: ACCESS_MANAGE.
Body: {"mode": "inherit"} — one of the three; empty is not allowed
here.
Refusal: 400 "inherit, inherit_locked or none."
Response: {"mode": "inherit"}.
GET /api/workflow-controls — how far workflows reach
No permission check. "Read by anybody signed in; changed on People and Persona by whoever manages access."
Response: {"api_writes": "direct", "bulk_loads": "direct"}.
PUT /api/workflow-controls
Permission: ACCESS_MANAGE — "set how far workflows reach".
Body — either or both:
| Field | Setting key | Allowed |
|---|---|---|
api_writes |
workflow_api_writes |
direct, propose, refuse |
bulk_loads |
workflow_bulk_loads |
direct, hold, reject |
Refusals: 400 "<field> is one of <a, b, c>."; 400 "Nothing to
change."
What each setting does
api_writes governs a direct write to a governed asset through the API
(_api_guard): direct writes as before; propose turns the
write into a change request and answers 202; refuse answers 409.
bulk_loads governs a workbook load (_load_gate): direct
writes; hold turns changes to governed assets into one request each, makes
governed new assets proposals, and holds links where either end is governed;
reject leaves those rows out and names them. WORKFLOW_BYPASS_LOAD loads
directly whatever the setting says.
Response: the same shape as the GET.
The People tab's four panels
GET / PUT /api/maintenance
Documented under Screen 1.
GET /api/signup-settings
Permission: ACCESS_MANAGE — "see how sign-up is set up".
Docstring: "What sign-up is set to, and — for an administrator — why it is closed."
{
"domains": ["acme.com"], // or ["*"] for any address
"role": "VIEWER", // the role a new account gets, or null
"roles": [ // every ACTIVE, GLOBAL role
{"code": "VIEWER", "name": "Viewer", "allowed": true}
],
"open": false,
"why": "Sign-up is closed until mail can be sent — a new account has to confirm its address. …",
"hours": 24, // SIGNUP_HOURS
"recent": [ // LIMIT 20, newest first
{"email": "x@acme.com", "display_name": "X", "created_at": "…",
"confirmed_at": null, "stale": false}
]
}
allowed is false for a role carrying ACCESS_MANAGE or VIEW_AS
(SIGNUP_FORBIDDEN).
why is for an administrator, and names the mail server and the role. The
public form says only SIGNUP_CLOSED — "a stranger does not
need to know that this repository's mail server is broken." The five reasons
_signup_state can give are: no domain listed; the role does not
exist or is switched off; the role is resource rather than global; the role
carries a forbidden permission; mail cannot be sent.
PUT /api/signup-settings
Permission: ACCESS_MANAGE — "change how sign-up works".
Body — either or both:
| Field | Type | Notes |
|---|---|---|
domains |
list or string | split on commas, whitespace and semicolons; @ stripped; lower-cased; de-duplicated |
role |
string | stripped, upper-cased |
Refusals
| Condition | Status | Message |
|---|---|---|
| a bad domain | 400 | "<x> is not a domain — write them as krisid.com, or * for any address." |
| no such active role | 400 | "There is no active role called <CODE>." |
a resource role |
400 | "<Name> is held over particular assets; a new account needs a role held over the whole repository." |
a role carrying ACCESS_MANAGE or VIEW_AS |
400 | "<Name> carries <PERM>. A role anybody can sign themselves up for must not be able to grant roles or look through somebody else's eyes — give it to people by hand instead." |
DOMAIN_RE requires 3 to 253 characters and a TLD of 2 to 63
letters. * with anything else collapses to just ["*"] — "not an error in
effect, but a list that says 'these three, and also everything' is a list
somebody will misread later."
Response: the same shape as the GET.
PUT /api/settings/view-as
Documented under Screen 1.
POST /api/invites — mint a sign-in link
Called from the People table and the invite panel.
Permission: ACCESS_MANAGE — "invite somebody to sign in".
Body
| Field | Type | Notes |
|---|---|---|
username |
string | stripped, lower-cased |
send |
bool | also email the link |
link |
string | ignored unless it already starts with <base>/#/invite/ |
The link field is the interesting one:
Built here, not taken from the caller. A link that arrives in the body is a link somebody could point anywhere, and this endpoint puts what it is given into an email over somebody's name — which is the shape of every phishing message ever sent. The browser may say which address it is being used at; it may not say where the link goes.
Refusals
| Condition | Status | Message |
|---|---|---|
| no such person | 404 | "There is nobody called <username> here." |
| inactive | 400 | "<Name> is marked inactive, so an invite would not let them in." |
send and they have no email |
400 | "<Name> has no email address. Add one and the link can be sent to it." |
send and mail is not ready |
400 | "<why> Set the mail server up on the Email screen under Administration, or copy the link and send it yourself." |
One live invite per person: minting a new one marks the old one used, "so a link somebody forwarded last week stops working the moment their administrator makes them another."
INVITE_HOURS = 72.
Response
{"username": "r.iyer", "display_name": "Rohan Iyer",
"email": "rohan@example.com",
"token": "<the raw token — the only time it is readable>",
"hours": 72,
"path": "/#/invite/<token>",
"sent": true, // null when `send` was not asked for
"said": "<what the mail server said>",
"can_send": true} // mail is ready AND they have an address
The address comes back with the link "so the screen can offer to send it by hand where there is no mail server — the message then goes through whatever the administrator already uses."