Docs › Reference

For everyone

People, teams, roles and access

The API calls behind people, teams, roles and access.

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:

  1. GET /api/access
  2. GET /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:

  1. 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 email already means "nobody has recorded one", with an editable field and an Invite button next to it. A client that reads email and finds the key missing has been told it was not served one; a client that found null would have been told something false.
  2. Your own row is always served, both fields, whether you hold the permission or not.
  3. The workbook refuses rather than redacts. GET /api/access/export is 403 without 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 ran owning_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_activity showed 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 source on 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.

offset rather 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.name spans 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.

asset IS NOT ONE OF THOSE, and it was in this list. SELECT id, name FROM asset on 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."