Server settings → People: an appointed administrator is a superuser, and a mounted Django admin shows them everyone's records #368

Closed
opened 2026-10-01 10:46:11 +00:00 by tiagoagueda · 1 comment
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading the People actions and the admin registrations against the threat model, and checking with a test that mounts the admin and appoints a second administrator.

Checked again on 2026-10-01 against main at 0a5cad587: still present. The code is now at core/server_views.py:227-230 (the grant in PersonAdminView.post) and core/server_views.py:1304 (PluginActionView), and the test that pins the grant at tests/test_server_settings.py:153-155, with the assertion on line 155; the other locations are unchanged.

Postulo promises that administrators see accounts and never anyone's applications or documents. Server settings keeps that promise, but the role is wider than Server settings: appointing an administrator from People sets is_superuser as well as is_staff, and every user-owned model is registered in Django's admin with nothing that narrows it to one owner. Once an operator sets POSTULO_ADMIN_URL, every administrator can therefore read and edit every member's job search there, and edits made there bypass change_status and record_event. Nothing tells the operator who mounts the admin, or the people on the instance. Separately, any administrator can install plugin code, which the same sentence does not allow for either.

  • Where:
    • The superuser grant:
      • core/server_views.py:226-229 (PersonAdminView.post): person.is_staff = True, then person.is_superuser = True;
      • accounts/signals.py:58-60 (first_account_is_the_administrator): the first account gets the same;
      • core/mixins.py:81-82 (StaffRequiredMixin.test_func) checks is_staff alone, and no check in src/postulo reads is_superuser, so nothing in Postulo's own pages needs it;
      • tests/test_server_settings.py:151-153 pins it: assert user.is_staff and user.is_superuser.
    • Registrations of user-owned models, most with owner as a list filter and none with a get_queryset or permission rule:
      • applications/admin.py:13-53 (Application with EventInline, ApplicationEvent, Reminder, Interview, Suggestion);
      • documents/admin.py:12-46 (CV, CoverLetter, UploadedDocument, RenderedDocument, DocumentCopy);
      • jobs/admin.py:18-62 (Company, Industry, Contact, JobPosting, Capture);
      • resume/admin.py:15-70 (the career record);
      • plugins/admin.py:6-17 (Connection, SyncLink);
      • core/admin.py:6-11 (Tag).
    • accounts/admin.py:14-29: User is registered on Django's UserAdmin, which lets a superuser set any account's password and flags.
    • core/admin_site.py:31-49 (ThrottledAdminSite): overrides only login; no has_permission and no queryset rule.
    • config/urls.py:35-36: mounts admin.site.urls when POSTULO_ADMIN_URL is set.
    • core/server_views.py:1303 (PluginActionView, a StaffRequiredMixin view): any administrator uploads and installs a wheel, which is code running with the whole database.
    • The promise, unqualified in three places:
      • docs/THREAT-MODEL.md:14: "Administrators see accounts, never anyone's applications or documents.";
      • templates/server/people.html:9, the same sentence, shown to administrators;
      • the wiki's Accounts-and-invitations.md:140-141.
  • What happens:
    1. On a shared instance (a household, a job club, a careers office), the operator mounts the admin as "the escape hatch" the wiki describes (Accounts-and-invitations.md:240).
    2. The operator appoints a second administrator from Server settings → People. In a test, that account came out with is_staff and is_superuser both set.
    3. That administrator's admin index lists applications/application, applications/applicationevent, documents/cv, documents/coverletter, documents/uploadeddocument, jobs/contact, plugins/connection and the rest, each with every account's rows. The application list filtered by another member's id answers 200 and shows the posting title.
    4. The change form carries a status field, so an application's status can be changed with no change_status and no event in the timeline. The User change form reaches other accounts' passwords and permission flags.
  • Who can trigger it: any account appointed administrator from People, on an instance whose operator has set POSTULO_ADMIN_URL. Mounting is opt-in and off by default (#116). No shell access is needed. The plugin point needs no mounted admin.
  • Why it matters:
    • The threat model is what every feature answers to, and the People page says the sentence to the very people it is about. A member deciding whether to keep their job search on a shared instance reads it as a statement about the role, not about one set of pages.
    • An operator who mounts the admin for their own use opens everybody's records to every co-administrator without being told. The settings comment (config/settings/base.py:374-382), the wiki's Hardening.md:271-293 and Configuration.md:20 describe the admin (Hardening: "a second and less careful way into the same data") and never say that it gives every administrator every person's data or that it voids the promise.
    • #24, which introduced the role, said: "underneath it stays is_staff, so nothing else changes". The code does more than that.
    • The only admin tests, tests/security/test_admin_exposure.py, cover mounting and throttling, not what the admin shows or to whom.
  • Fix:
    1. In PersonAdminView.post, stop setting is_superuser on appointment (keep clearing it on removal). Leave is_superuser to createsuperuser at the console and to the first account (accounts/signals.py:58-60), which is the operator's own. A staff account with no model permissions then sees nothing in a mounted admin unless the operator grants it deliberately, and the admin becomes the operator's alone. Whether a data migration also clears is_superuser on administrators appointed earlier is the maintainer's choice; either way the release notes must say so, because an operator who relies on co-administrators using the admin has to grant it again at the console.
    2. Decide what the admin is for. Either keep it to instance-level models (User, Invite, ApiToken metadata, the instance's settings) by dropping the registration of every OwnedModel subclass and of ApplicationEvent (a plain models.Model that belongs to an application), or give those registrations a base ModelAdmin whose has_view_permission, has_add_permission, has_change_permission and has_delete_permission return False. That also removes the route by which a status is changed outside the event log. If full access for the operator's superuser is intended instead, keep the registrations and say so in the documents below.
    3. Say what is true wherever the promise is made (docs/THREAT-MODEL.md, templates/server/people.html, the wiki's Accounts and invitations) and wherever the admin is described (the settings comment, the wiki's Hardening and Configuration): Server settings shows administrators accounts and never anyone's records, but an administrator is trusted with the instance, since they install plugins; and a superuser in a mounted admin reaches every table, with edits that bypass the event log. Appoint only people you would trust with the database. Step 2 alone would not make the original sentence true while wheel installation is open to every administrator.

Related: #268 discusses whether an administrator may ever see another person's records and says "An administrator who could grant themselves access is the whole promise gone." It does not cover the admin or the superuser flag. That the admin's own sign-in form takes a password alone is reported separately.

Done when

  • tests/test_server_settings.py: appointing a member through server:person_admin leaves is_superuser false, and removing an administrator still clears it. Today line 153 asserts the opposite.
  • tests/security/test_admin_exposure.py: with the admin mounted, an account appointed through server:person_admin is refused at the admin's applications/application/ changelist and is offered no owned model on the admin index. It lists every account's applications today.
  • If the registrations are dropped or locked: tests/security/test_admin_exposure.py walks apps.get_models() and asserts that neither an OwnedModel subclass nor ApplicationEvent is viewable or editable through admin.site, and that a superuser gets no page listing another person's applications. Both fail today.
  • docs/THREAT-MODEL.md, templates/server/people.html, the settings comment and the wiki's Accounts and invitations, Hardening and Configuration pages state the administrator's reach as in step 3.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading the People actions and the admin registrations against the threat model, and checking with a test that mounts the admin and appoints a second administrator. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. The code is now at `core/server_views.py:227-230` (the grant in `PersonAdminView.post`) and `core/server_views.py:1304` (`PluginActionView`), and the test that pins the grant at `tests/test_server_settings.py:153-155`, with the assertion on line 155; the other locations are unchanged. Postulo promises that administrators see accounts and never anyone's applications or documents. *Server settings* keeps that promise, but the role is wider than *Server settings*: appointing an administrator from *People* sets `is_superuser` as well as `is_staff`, and every user-owned model is registered in Django's admin with nothing that narrows it to one owner. Once an operator sets `POSTULO_ADMIN_URL`, every administrator can therefore read and edit every member's job search there, and edits made there bypass `change_status` and `record_event`. Nothing tells the operator who mounts the admin, or the people on the instance. Separately, any administrator can install plugin code, which the same sentence does not allow for either. - **Where:** - The superuser grant: - `core/server_views.py:226-229` (`PersonAdminView.post`): `person.is_staff = True`, then `person.is_superuser = True`; - `accounts/signals.py:58-60` (`first_account_is_the_administrator`): the first account gets the same; - `core/mixins.py:81-82` (`StaffRequiredMixin.test_func`) checks `is_staff` alone, and no check in `src/postulo` reads `is_superuser`, so nothing in Postulo's own pages needs it; - `tests/test_server_settings.py:151-153` pins it: `assert user.is_staff and user.is_superuser`. - Registrations of user-owned models, most with `owner` as a list filter and none with a `get_queryset` or permission rule: - `applications/admin.py:13-53` (`Application` with `EventInline`, `ApplicationEvent`, `Reminder`, `Interview`, `Suggestion`); - `documents/admin.py:12-46` (`CV`, `CoverLetter`, `UploadedDocument`, `RenderedDocument`, `DocumentCopy`); - `jobs/admin.py:18-62` (`Company`, `Industry`, `Contact`, `JobPosting`, `Capture`); - `resume/admin.py:15-70` (the career record); - `plugins/admin.py:6-17` (`Connection`, `SyncLink`); - `core/admin.py:6-11` (`Tag`). - `accounts/admin.py:14-29`: `User` is registered on Django's `UserAdmin`, which lets a superuser set any account's password and flags. - `core/admin_site.py:31-49` (`ThrottledAdminSite`): overrides only `login`; no `has_permission` and no queryset rule. - `config/urls.py:35-36`: mounts `admin.site.urls` when `POSTULO_ADMIN_URL` is set. - `core/server_views.py:1303` (`PluginActionView`, a `StaffRequiredMixin` view): any administrator uploads and installs a wheel, which is code running with the whole database. - The promise, unqualified in three places: - `docs/THREAT-MODEL.md:14`: "Administrators see accounts, never anyone's applications or documents."; - `templates/server/people.html:9`, the same sentence, shown to administrators; - the wiki's `Accounts-and-invitations.md:140-141`. - **What happens:** 1. On a shared instance (a household, a job club, a careers office), the operator mounts the admin as "the escape hatch" the wiki describes (`Accounts-and-invitations.md:240`). 2. The operator appoints a second administrator from *Server settings → People*. In a test, that account came out with `is_staff` and `is_superuser` both set. 3. That administrator's admin index lists `applications/application`, `applications/applicationevent`, `documents/cv`, `documents/coverletter`, `documents/uploadeddocument`, `jobs/contact`, `plugins/connection` and the rest, each with every account's rows. The application list filtered by another member's id answers `200` and shows the posting title. 4. The change form carries a `status` field, so an application's status can be changed with no `change_status` and no event in the timeline. The `User` change form reaches other accounts' passwords and permission flags. - **Who can trigger it:** any account appointed administrator from *People*, on an instance whose operator has set `POSTULO_ADMIN_URL`. Mounting is opt-in and off by default (#116). No shell access is needed. The plugin point needs no mounted admin. - **Why it matters:** - The threat model is what every feature answers to, and the *People* page says the sentence to the very people it is about. A member deciding whether to keep their job search on a shared instance reads it as a statement about the role, not about one set of pages. - An operator who mounts the admin for their own use opens everybody's records to every co-administrator without being told. The settings comment (`config/settings/base.py:374-382`), the wiki's `Hardening.md:271-293` and `Configuration.md:20` describe the admin (*Hardening*: "a second and less careful way into the same data") and never say that it gives every administrator every person's data or that it voids the promise. - #24, which introduced the role, said: "underneath it stays `is_staff`, so nothing else changes". The code does more than that. - The only admin tests, `tests/security/test_admin_exposure.py`, cover mounting and throttling, not what the admin shows or to whom. - **Fix:** 1. In `PersonAdminView.post`, stop setting `is_superuser` on appointment (keep clearing it on removal). Leave `is_superuser` to `createsuperuser` at the console and to the first account (`accounts/signals.py:58-60`), which is the operator's own. A staff account with no model permissions then sees nothing in a mounted admin unless the operator grants it deliberately, and the admin becomes the operator's alone. Whether a data migration also clears `is_superuser` on administrators appointed earlier is the maintainer's choice; either way the release notes must say so, because an operator who relies on co-administrators using the admin has to grant it again at the console. 2. Decide what the admin is for. Either keep it to instance-level models (`User`, `Invite`, `ApiToken` metadata, the instance's settings) by dropping the registration of every `OwnedModel` subclass and of `ApplicationEvent` (a plain `models.Model` that belongs to an application), or give those registrations a base `ModelAdmin` whose `has_view_permission`, `has_add_permission`, `has_change_permission` and `has_delete_permission` return `False`. That also removes the route by which a status is changed outside the event log. If full access for the operator's superuser is intended instead, keep the registrations and say so in the documents below. 3. Say what is true wherever the promise is made (`docs/THREAT-MODEL.md`, `templates/server/people.html`, the wiki's *Accounts and invitations*) and wherever the admin is described (the settings comment, the wiki's *Hardening* and *Configuration*): *Server settings* shows administrators accounts and never anyone's records, but an administrator is trusted with the instance, since they install plugins; and a superuser in a mounted admin reaches every table, with edits that bypass the event log. Appoint only people you would trust with the database. Step 2 alone would not make the original sentence true while wheel installation is open to every administrator. Related: #268 discusses whether an administrator may ever see another person's records and says "An administrator who could grant themselves access is the whole promise gone." It does not cover the admin or the superuser flag. That the admin's own sign-in form takes a password alone is reported separately. ## Done when - `tests/test_server_settings.py`: appointing a member through `server:person_admin` leaves `is_superuser` false, and removing an administrator still clears it. Today line 153 asserts the opposite. - `tests/security/test_admin_exposure.py`: with the admin mounted, an account appointed through `server:person_admin` is refused at the admin's `applications/application/` changelist and is offered no owned model on the admin index. It lists every account's applications today. - If the registrations are dropped or locked: `tests/security/test_admin_exposure.py` walks `apps.get_models()` and asserts that neither an `OwnedModel` subclass nor `ApplicationEvent` is viewable or editable through `admin.site`, and that a superuser gets no page listing another person's applications. Both fail today. - `docs/THREAT-MODEL.md`, `templates/server/people.html`, the settings comment and the wiki's *Accounts and invitations*, *Hardening* and *Configuration* pages state the administrator's reach as in step 3.
tiagoagueda added this to the 0.5.0 milestone 2026-10-01 10:46:11 +00:00
Author
Owner

Landed on main as ba5313a06 (wiki 56d1f04).

Decisions: the owned-model admin registrations are dropped, not locked (less code, and no route that changes a status outside the event log). ApiToken is the one owned model kept, because revoking a leaked token is an operator's job; its admin is view/delete only, with no add or change. No data migration: the first account and a createsuperuser account look the same as an appointed one, so the changelog entry and the wiki's Accounts and invitations give the one-line command that clears the flag from earlier appointees, naming your own account.

The new walk in tests/security/test_admin_exposure.py goes over apps.get_models(), so a future registration of an OwnedModel (or one added by an installed plugin) fails it.

Not touched: any administrator can still install plugin wheels. The threat model, the People page, the settings comment and the wiki now say so rather than leaving the sentence to imply otherwise.

The browser suite has four failures in test_board_fold.py::test_the_focus_ring_of_a_heading_is_not_cut_by_the_boards_box (ring cut by 0.47px); they fail the same on the commit before this one and are unrelated.

Landed on `main` as `ba5313a06` (wiki `56d1f04`). Decisions: the owned-model admin registrations are **dropped**, not locked (less code, and no route that changes a status outside the event log). `ApiToken` is the one owned model kept, because revoking a leaked token is an operator's job; its admin is view/delete only, with no add or change. No data migration: the first account and a `createsuperuser` account look the same as an appointed one, so the changelog entry and the wiki's *Accounts and invitations* give the one-line command that clears the flag from earlier appointees, naming your own account. The new walk in `tests/security/test_admin_exposure.py` goes over `apps.get_models()`, so a future registration of an `OwnedModel` (or one added by an installed plugin) fails it. Not touched: any administrator can still install plugin wheels. The threat model, the People page, the settings comment and the wiki now say so rather than leaving the sentence to imply otherwise. The browser suite has four failures in `test_board_fold.py::test_the_focus_ring_of_a_heading_is_not_cut_by_the_boards_box` (ring cut by 0.47px); they fail the same on the commit before this one and are unrelated.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#368
No description provided.