Docs › Reference

For everyone

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

  1. GET /api/brand
  2. GET /api/ask/home — lazily, to show the generated examples beside the box
  3. PUT /api/brand {bar} or {name}
  4. POST /api/brand/logo as a raw fetch with FormData
  5. DELETE /api/brand/logo
  6. PUT /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.

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":

  1. 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.
  2. 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.
  3. It is served with nosniff and 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}.

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."

Email

The order of calls

  1. GET /api/mail
  2. PUT /api/mail per field, and separately for the password
  3. POST /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

  1. GET /api/logs/settings
  2. GET /api/logs?channel=<c>&limit=100&offset=<n>&q=<q>
  3. 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 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_log because a trigger puts them there; email is in mail_log; sign-ins, uploads and harvests are in event_log because 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

  1. GET /api/usage/state (, via the frame)
  2. GET /api/usage/summary?<filter>
  3. GET /api/usage/networks — lazily, for the naming panel
  4. PUT /api/usage/settings per switch
  5. PUT /api/usage/networks
  6. DELETE /api/usage
  7. GET /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.