Administration
The API calls behind administration.
On this page
Part of the HTTP API reference.
Five routes, plus the panels on Access → People already covered under Screen 7.
| Route | Draw function |
|---|---|
#/appearance |
drawBrandScreen |
#/email |
drawMail → mailPanel |
#/logs or #/logs/<channel> |
drawLogs(channel) |
#/usage?<query> |
drawUsage(query) |
Appearance
The order of calls
GET /api/brandGET /api/ask/home— lazily, to show the generated examples beside the boxPUT /api/brand{bar}or{name}POST /api/brand/logoas a rawfetchwithFormDataDELETE /api/brand/logoPUT /api/ask/examples
PUT /api/brand
Permission: ACCESS_MANAGE — "change how this repository is branded".
Body — one or both:
| Field | Notes |
|---|---|
name |
stripped; 1 to 40 characters |
bar |
stripped, lower-cased; "dark" or "light" |
Refusals
| Condition | Status | Message |
|---|---|---|
a bar that is neither |
400 | "The top bar is dark or light, not '<x>'. Light is for a logo drawn in a dark colour, which vanishes into a dark bar." |
| an empty name | 400 | "A repository needs a name in the corner. Put something there, even if it is only a word." |
| over 40 characters | 400 | "That will not fit in the top-left. Forty characters is already generous." |
| neither field present | 400 | "What can be set here: name, bar." |
Response: the GET /api/brand shape.
Settings are UPSERTED, not updated (_set_setting) — this is a
fixed bug worth reading:
An UPDATE that matches no row succeeds. It affects nothing, returns no error, and the endpoint above it answers 200 — so the screen redraws with the old value and the control looks as though it is not wired up. That is exactly what happened: a client unpacked a release, pressed the new choice on the Appearance screen, and it did nothing, because their database had never been given the row the release introduced.
So a setting is UPSERTED. The row not existing is not a failure to report, it is the ordinary state of a database that has the new code and has not yet had the new seed — and a feature that only works after somebody remembers a second command is a feature that is broken.
POST /api/brand/logo
Permission: ACCESS_MANAGE — "change this repository's logo".
multipart/form-data with one field, file.
The three rules — this is "the one endpoint where somebody uploads a file that the application then serves back from its OWN origin. That is the shape of a stored cross-site-scripting hole":
- SVG is refused. An SVG is not a picture, it is a document, and a document served from this origin can carry script that runs with the signed-in person's session. Every other product that learned this learned it the hard way.
- The type comes from the BYTES, never from the filename or from what the browser claimed. A .png that begins with '<' is not a png.
- It is served with
nosniffand as an attachment-safe image type, so a browser cannot be talked into reading it as anything else.
Refusals
| Condition | Status | Message |
|---|---|---|
| empty file | 400 | "That file is empty." |
over LOGO_MAX = 512 KB |
400 | "That is <n> KB. A logo in a header should be under 512 KB — anything bigger is being resized by the browser on every page anyway." |
<svg or <?xml in the first 400 bytes (lower-cased, left-stripped) |
400 | "SVG is not accepted. An SVG is a document rather than a picture: it can carry script, and this file is served back from the application's own address, so a signed-in person would run it. Export it as a PNG." |
| the bytes are not a recognised image | 400 | "That is not a PNG, JPEG, GIF or WEBP. What a file is called does not decide what it is, and these bytes begin <repr of the first 4 bytes>." |
What is sniffed (MAGIC): \x89PNG\r\n\x1a\n → image/png;
\xff\xd8\xff → image/jpeg; GIF87a/GIF89a → image/gif; RIFF at 0
with WEBP at 8 → image/webp.
Response: {"ok": true, "logo_type": "image/png", "bytes": 18402}.
DELETE /api/brand/logo
Permission: ACCESS_MANAGE — "remove this repository's logo".
Clears both brand_logo and brand_logo_type. Response: the
GET /api/brand shape. A screen logbook line reads "the logo was removed;
the word mark is back".
PUT /api/ask/examples
Permission: ACCESS_MANAGE — "change the Ask page's example questions".
Body: {"examples": ["…", "…"]} or {"examples": "one per line"}.
Refusals
| Condition | Status | Message |
|---|---|---|
| neither a list nor a string | 400 | "Send the examples as a list of questions, or as text with one question per line." |
| more than 12 | 400 | "Twelve at most. The page shows the first few, and a long list under a search box is a list nobody reads." |
| one over 90 characters | 400 | "“<first 40>…” is too long to read as an example. Ninety characters at most." |
Blank lines are dropped.
Response
{"examples": [{"q": "Tables with PII in Banking", "answers": true}]}
Every written question is run and the result said back: "a question an administrator put on the front page that answers nothing is worth knowing about before anybody else finds out."
There is no cache to drop: "_examples is keyed on these very words, so
saving them has already changed the key — which is what makes this immediate
for a second web process too, where popping a dict in this one would have done
nothing."
The order of calls
GET /api/mailPUT /api/mailper field, and separately for the passwordPOST /api/mail/test
GET /api/mail
Permission: ACCESS_MANAGE — "see how this repository sends email".
Docstring: "What the mail server is set to. Never the password — not even masked. A password that comes back to the browser is a password in a browser history, a screenshot and a support ticket. The screen is told whether one is set, and that is all it needs to draw itself."
Response
{
// mailer.settings(cur), minus the encrypted password:
"host": "smtp.example.com", "port": "587", "security": "starttls",
"username": "krisid@example.com",
"from_address": "krisid@example.com", "from_name": "KRISID",
"enabled": "on",
"password_set": true,
"ready": true, // mailer.ready
"why": "", // why not, when not ready
"your_address": "rohan@example.com", // where a test would go with nothing typed
"password_length": 19, // THE LENGTH, never the password
"password_set_at": "…",
"password_set_by": "r.iyer",
"key_source": "file", // vault.key_source
"key_said": "…",
"log": [ // LIMIT 20, newest first
{"at": "…", "sent_to": "…", "subject": "…", "ok": true,
"detail": "…", "about": "invite", "sent_by": "r.iyer"}
]
}
password_length is "the one thing that tells somebody at a glance that they
pasted a Google app password with its spaces still in it — 19 characters where
16 were meant."
your_address is given "so the box can say so rather than letting somebody
press the button and be told after."
password_set_at / password_set_by are "the question a rotation starts
with" — the time is the setting's own; the name comes from the system event
recorded when it was stored.
PUT /api/mail
Permission: ACCESS_MANAGE — "change how this repository sends email".
Docstring: "The password is encrypted here and nowhere else, so there is one line in this application that turns a secret into stored text — which is the only way to be sure of what is stored."
Body — one or more; at least one required.
| Field | Setting key | Validation |
|---|---|---|
host |
mail_host |
stripped |
port |
mail_port |
an integer 1…65535 |
security |
mail_security |
starttls, ssl, none |
username |
mail_username |
stripped |
from_address |
mail_from |
EMAIL_RE, when non-empty |
from_name |
mail_from_name |
stripped |
enabled |
mail_enabled |
True, "on", "true" or "Yes" → "on"; anything else → "off" |
password |
mail_password |
tidied by mailer.tidy_password, then sealed; empty clears it |
Refusals
| Condition | Status | Message |
|---|---|---|
| nothing recognised | 400 | "What can be set: enabled, from_address, from_name, host, port, security, username, password." |
a bad security |
400 | "How the connection is protected: starttls (587), ssl (465), or none. Nothing else is a setting a mail server understands." |
| a non-numeric port | 400 | "'<x>' is not a port number." |
| a port out of range | 400 | "A port is between 1 and 65535." |
a bad from_address |
400 | "'<addr>' is not an email address." |
Clearing the password has to be possible: "a password nobody can clear is a password nobody can rotate."
The password change is logged to the system channel, not email:
"the Email tab lists messages from mail_log, so an event there would be
recorded and never seen."
Response: the GET /api/mail shape.
POST /api/mail/test
Permission: ACCESS_MANAGE — "send email from this repository".
Body: {"to": "somebody@example.com"} — optional; defaults to the
acting person's own address.
Docstring: "Send one message, now, and say exactly what the server said. To the administrator's own address by default: the first thing anybody wants after typing in a mail server is proof, and proof that goes to somebody else is not proof."
Refusals
| Condition | Status | Message |
|---|---|---|
| no address anywhere | 400 | "There is no address to send the test to. Put one on your own account, or type one here." |
| a bad address | 400 | "'<to>' is not an email address." |
| the send failed | 502 | whatever the mail server said, verbatim |
The attempt is recorded in mail_log either way, with about = "test".
Response: {"sent": true, "to": "…", "detail": "<what the server said>"}.
Logs
Route: #/logs/<channel> — channel is [a-z]+.
The order of calls
GET /api/logs/settingsGET /api/logs?channel=<c>&limit=100&offset=<n>&q=<q>PUT /api/logs/viewers
PUT /api/logs/keep exists and no screen calls it; see the final section.
Who may read the logs
_may_read_logs — two ways in:
the permission, or being named on the Logs screen. Named people are the half a client always asks for — "and these two, who are not administrators". Neither route is required to be the only one, and both are visible on the same screen, so nobody has to guess which one is letting somebody in.
The refusal (_logs_or_refuse) names both:
403 "<Name> may not read the logs. That needs LOG_VIEW, which comes with
Sysadmin — or being named on the Logs screen by somebody who administers
access."
GET /api/logs/settings
Permission: LOG_VIEW or being named.
Docstring: "What the Logs screen needs to draw itself: the channels with their counts, who may read them, and how long anything is kept."
Response
{
"channels": [ // LOG_CHANNELS, in order
{"code": "email", "name": "Email", "rows": 412}
],
"named": [ // ORDER BY lower(display_name)
{"uid": "…", "username": "a.khan", "display_name": "A. Khan",
"added_by": "superadmin", "added_at": "…"}
],
"by_role": [ // who already holds LOG_VIEW through a role
{"username": "superadmin", "display_name": "Superadmin"}
],
"may_configure": true, // holds ACCESS_MANAGE
"keep_days": 90, // log_keep_days, default "90"
"file": "/path/to/logs/krisid.log"
}
by_role exists "so the screen can say 'these people can already see this'
rather than listing only the exceptions."
The twelve channels
LOG_CHANNELS, and where each one's rows come from:
| Code | Name | Source |
|---|---|---|
email |
mail_log |
|
signin |
Sign-in | event_log |
asset |
Asset edits | change_log over asset, asset_attribute, relation |
model |
Asset model | change_log over asset_type, attribute_type, type_attribute, relation_type |
access |
Access | change_log over ACCESS_TABLES |
workflow |
Workflow | change_log over the seven workflow tables |
load |
Migration and loads | event_log |
catalogue |
Cataloguing | event_log |
lineage |
Lineage | event_log |
intake |
Lineage received | event_log |
system |
System | event_log |
runtime |
Runtime | the rotating log file, not the database |
Why there is no new table:
One screen, several channels, and NOT one new table holding copies of what is already recorded. Asset edits, model changes and access changes are in
change_logbecause a trigger puts them there; email is inmail_log; sign-ins, uploads and harvests are inevent_logbecause they change no row. The reader below knows which is which. A copy would be a second truth, and the one that is wrong is always the copy nobody remembered existed.
Why cataloguing and lineage are two channels, not one:
A harvest is two phases and they fail differently: cataloguing is "did the things arrive", lineage is "did the links between them". Somebody chasing a missing table and somebody chasing a missing arrow are looking for different events, and one channel made each of them read the other's.
Why intake is separate from both:
Receiving is not harvesting. An event arriving says a producer is wired up correctly; a catalogue run says what this repository made of it, hours later.
GET /api/logs
Permission: LOG_VIEW or being named.
Query parameters
| Name | Type | Default | Clamped |
|---|---|---|---|
channel |
string | "email" |
an unknown value falls through to the event_log branch and returns nothing |
limit |
int | 100 |
max(1, min(limit, 500)) |
offset |
int | 0 |
none |
q |
string | "" |
see below |
Docstring: "One channel of the log, newest first. Every channel answers the same shape — when, who, what, and the detail — so the screen draws them all with one table rather than nine."
What q searches, per channel:
| Channel | Columns |
|---|---|
email |
sent_to, detail, subject |
a change_log channel |
actor, after::text, before::text |
an event_log channel |
summary, actor, detail::text |
All ILIKE %q%; an empty or whitespace q means no filter.
Response — the ordinary shape
{
"channel": "asset",
"rows": [
{"at": "…", "actor": "r.iyer",
"actor_name": "Rohan Iyer", "actor_kind": "person",
"level": "info", // info | warn | error | trouble
"kind": "update asset", // per channel; see below
"what": "CUSTOMERS",
"detail": null}
],
"has_more": true,
"next_offset": 100
}
kind is coalesce(about, 'email') for the email channel,
action || ' ' || table_name for a change_log channel, and the event_log
channel name otherwise. what for a change_log row is the first of
after->>'name', before->>'name', after->>'display_name',
before->>'display_name', after->>'code', before->>'code',
'row ' || row_id.
level for the email channel is 'info' when ok and 'trouble' otherwise;
for a change_log channel it is always 'info'.
Names rather than usernames, "the same as History — 'who' is a person" — the
same _actor_names mapping documented under Screen 4.
Response — the runtime channel, which reads the file, not the database:
{"channel": "runtime",
"runtime": ["…", "…"], // logbook.tail(min(limit, 500)) — raw lines
"file": "/path/to/logs/krisid.log",
"rows": [],
"has_more": false}
PUT /api/logs/viewers
Permission: ACCESS_MANAGE — "decide who may read the logs".
Body: {"usernames": ["a.khan", "b.osei"]} — stripped, lower-cased, blanks
dropped. The whole list, every time.
Docstring: "A whole list rather than add-one/remove-one because that is what the picker on the screen holds, and because 'who can see this' is a question with one answer, not a sequence of edits somebody has to replay to understand."
Refusal: 400 "There is nobody here called <a>, <b>."
An empty list clears the table.
Response: the GET /api/logs/settings shape.
PUT /api/logs/keep
Permission: ACCESS_MANAGE — "change how long logs are kept".
Not called by any screen; see the final section.
Body: {"days": 90}.
Refusals: 400 "A number of days."; 400
"Between a day and ten years. Nothing shorter is worth keeping and nothing
longer is a decision anybody has thought about."
So 1 <= days <= 3650.
Docstring: "How long the trimmable logs are kept. History is not one of them."
Response: {"keep_days": 90}.
Usage
Route: #/usage?<query> → drawUsage(query). The query string
holds the filter, so a filtered view is a link.
The order of calls
GET /api/usage/state(, via the frame)GET /api/usage/summary?<filter>GET /api/usage/networks— lazily, for the naming panelPUT /api/usage/settingsper switchPUT /api/usage/networksDELETE /api/usageGET /api/usage/export?<filter>as an<a href>
If may_view is false the screen draws one sentence and makes no further
calls: "Seeing usage analytics needs USAGE_VIEW, which comes with Sysadmin
unless your access model says otherwise".
GET /api/usage/summary
Permission: USAGE_VIEW — "see usage analytics".
Query parameters
| Name | Type | Default | Notes |
|---|---|---|---|
from |
date | 29 days ago | ISO; the parameter is from, the Python name is frm |
to |
date | today | ISO |
user |
string | null |
one username |
role |
string | null |
a role code — everybody holding it, teams included |
team |
string | null |
a team code or uid |
Refusal: 400 "Dates are written 2026-09-22."
If to < from they are swapped (_usage_range). The range is
inclusive of to (the SQL uses to + 1 day).
Response
{
"from": "2026-09-02", "to": "2026-10-01",
"tiles": {
"people": 41, "signins": 312, "screens": 8402, "searches": 1204,
"searches_empty": 88, // no results AND no composed answer
"actions": 412, "time_ms": 184203000
},
"days": [{"day": "2026-09-02", "people": 12, "events": 404}],
"screens": [ // LIMIT 25, most opened first
{"name": "asset", "label": "An asset's page",
"opens": 4120, "people": 31, "avg_ms": 41230}
],
"searches": [ // LIMIT 25
{"words": "customer email", "times": 44, "people": 12,
"results": 81, "opened": 18}
],
"empty_searches": [ // LIMIT 25 — the gap list
{"words": "data product owner", "times": 12, "people": 4, "last": "…"}
],
"actions": [{"name": "edit_asset", "label": "Edited an asset",
"times": 88, "people": 9}],
"types": [{"code": "TABLE", "plural": "Tables", "opens": 1204, "people": 22}],
"assets": [{"uid": "…", "name": "CUSTOMERS", "type_name": "Table",
"opens": 412, "people": 18}],
"people": [ // LIMIT 200
{"username": "r.iyer", "display_name": "Rohan Iyer",
"events": 4120, "signins": 22, "searches": 112, "actions": 41,
"time_ms": 18420000, "days": 18, "last": "…",
"place": "Head office"} // the name, or the masked network
],
"places": [ // LIMIT 50
{"place": "192.168.4.0/24", "named": null,
"signins": 312, "people": 41, "last": "…"}
],
"choices": { // for the filter pickers
"roles": [{"code","name"}], "teams": [{"code","name"}],
"users": [{"username","display_name"}] // only people with events
},
"state": { … } // the GET /api/usage/state shape
}
SCREEN_WORDS maps the route's first segment to a label;
ACTION_WORDS maps an endpoint function name to one. Anything
unlisted falls back to the raw name with underscores or dashes turned to spaces
and the first letter capitalised.
What an "action" is (_usage_action), recorded by the
middleware after every API call: a write that worked, or a GET whose path
contains export. Anything under /api/usage and /api/session is skipped,
as is anything answering 400 or worse. The target is the first path parameter,
or — for an export — the type query parameter.
GET /api/usage/networks
Permission: USAGE_VIEW — "see which networks are named".
Response
{"networks": [ // ORDER BY name, cidr
{"uid": "…", "cidr": "192.168.4.0/24", "name": "Head office",
"note": null, "created_by": "…", "created_at": "…"}
],
"may_manage": false}
PUT /api/usage/networks
Permission: USAGE_MANAGE — "name the networks people sign in from".
Body: {"networks": [{"cidr": "10.20.0.0/16", "name": "VPN", "note": "…"}]}
— at most 200, replaces the whole table.
Docstring: "The whole map at once, like the access grid: what somebody decides is 'these are our networks', and saving it a row at a time leaves half a map behind whenever one row is refused."
Refusals
| Condition | Status | Message |
|---|---|---|
| not a list | 400 | "Send the networks as a list." |
| one of the two given and not the other | 400 | "Every network needs an address range and a name." |
| an unparseable CIDR | 400 | "'<x>' is not an address range. Write it as 10.20.0.0/16 for a network, or 203.0.113.7/32 for one address." |
| the same CIDR twice | 400 | "<cidr> is named twice." |
A row with neither a CIDR nor a name is skipped. CIDRs are normalised through
ipaddress.ip_network(strict=False); names are truncated to 120 characters and
notes to 400. The permission is checked after the validation, so a malformed
body is a 400 even without USAGE_MANAGE.
Response: the same shape as the GET, with may_manage: true.
PUT /api/usage/settings
Permission: USAGE_MANAGE — "switch usage analytics".
Body — one or more; at least one required.
| Field | Setting | Notes |
|---|---|---|
on |
usage_tracking |
truthy → "on" |
places |
usage_places |
truthy → "on" |
keep_days |
usage_keep_days |
an integer 7…3650 |
Refusals: 400 "Keep it for a whole number of days."; 400 "Keep it for
between 7 and 3,650 days."; 400 "Nothing to change."
Places is its own switch on purpose: "Where people sign in from is its own decision, so it is its own switch: recording what people do and recording where they are are not the same thing to agree to." The screen says the network only, masked, "named from your own list below. Nothing is looked up anywhere."
Response: the GET /api/usage/state shape.
DELETE /api/usage
Permission: USAGE_MANAGE — "delete recorded usage".
Docstring: "Delete everything recorded. Recording carries on if it is on."
Response: {"deleted": 184203}.
GET /api/usage/export
Permission: USAGE_VIEW — "export usage analytics".
Same query parameters as the summary. Hard LIMIT 200000.
Response: xlsx, filename
krisid-usage-<from>-to-<to>.xlsx, with these columns:
When (local time, %Y-%m-%d %H:%M:%S), Username, Name, Kind, What,
Which, Seconds (rounded from duration_ms), Results (searches only),
Opened (searches only), Where from ("<place> (<cidr>)", or whichever of
the two there is).
Kind is one of Screen, Search, Action, Signed in, Signed out.
Side effect: the export itself records a usage_export action.
Pruning (_prune_usage): the background loop deletes
anything older than usage_keep_days (default 180), at most once an hour.