Several people on one instance: limits, first-run setup, and whether anything may be shared #268

Open
opened 2026-09-17 19:36:06 +00:00 by tiagoagueda · 0 comments
Owner

Postulo is already multi-user, and more completely than this issue's title suggests. What is
missing is four things, filed together because two of them require amending a promise stated
in the README, and that amendment should be made once rather than twice.

What exists, so that none of it is rebuilt

  • Invite: one signup, optionally bound to one address, expiring on its own. An instance is
    invite-only unless the operator opens registration deliberately
    (config/settings/base.py:347).
  • Server settings → People: every account, with role — administrator, member, deactivated —
    last sign-in, and per-person actions for username, plugins, recovery and deletion.
  • The page states the boundary in its own subtitle: "Administrators see accounts, never
    anyone's applications or documents."
  • OwnedModel and for_user() on every query; OwnedObjectMixin makes a foreign object a
    404 rather than a 403.
  • OIDC, social sign-in and MFA, each with its own signup policy.

Administration is not the gap. The gap is what one account may consume, how an operator
starts, and whether anything may ever be shared.


Part 1 — Per-person limits and fair use

Nothing caps what one account consumes. There is no quota anywhere in the tree: not for
documents, not for logos, not for exports, not for captures per person.

On a single-person instance that is correct and should stay the default. On an instance with
a dozen people it means one account can fill the disk and the first anybody knows is that
everybody's uploads start failing — and on SQLite, that a write fails at an unpredictable
place.

What it needs:

  • a per-person storage figure that can be computed without walking the disk on every page
    load, and shown to the person themselves as well as to the operator;
  • a ceiling, off by default, set per instance and optionally per person;
  • the capture allowance divided rather than shared. POSTULO_CAPTURE_RATE is already
    spent against the owner's account (#194), so this is closer than the rest — what is missing
    is an instance-wide view of it;
  • a refusal that is a sentence, not a 500: somebody at their ceiling should be told, on the
    page, with what to delete;
  • the operator's view in Server settings → People, which already has the column space.

Depends on nothing. Could be built first and independently of everything below.

Part 2 — First-run setup for an instance

Bringing up an instance today means environment variables, a createsuperuser, and then
finding the right pages in Server settings. Each step is documented; nothing walks anybody
through them, and the order is only obvious to somebody who already knows it.

What it needs:

  • a first-run path that runs when there is no account yet: create the first administrator,
    choose invite-only or open registration, set the instance's name and default language, test
    mail, issue the first invitation;
  • it must be unreachable once an account exists — a setup page that stays open is an
    account-creation hole, and this is the one part of this issue with a sharp security edge;
  • honest about what can wait: mail can be configured later, and saying so is better than a
    wizard that will not let somebody in without an SMTP server;
  • reachable in the container on first boot without a shell.

Depends on nothing. Good candidate to land beside Part 1.


Parts 3 and 4 need one decision made first

The README promises one person's data never reaches another's. for_user() is not a
convention that implements that promise — it is the promise, expressed as the only
authorisation model the application has.

Both of the parts below require an object to be legitimately visible to somebody who does not
own it. That cannot be added twice, in two shapes, by two issues. The primitive is one
thing: an explicit, scoped, revocable grant, visible to the person who gave it.

The promise it becomes, and this wording should be settled here before any code:

No one sees another person's data unless that person granted it — explicitly, for a stated
scope, revocably, and visibly to them at any time.

That is still a strong promise. It is a different one, and the README, docs/THREAT-MODEL.md
and the wiki all state the current version.

Part 3 — Shared access between people

An employment-service caseworker, a coach, or an advisor seeing a job seeker's record. This
is a real use of a self-hosted instance and CompanyKind.EMPLOYMENT_SERVICE already shows
the domain is understood.

  • The grant is the job seeker's to give and to withdraw, never the operator's to assign.
    An administrator who could grant themselves access is the whole promise gone.
  • Scoped: applications and their timeline, perhaps; documents, probably not by default; the
    profile, no.
  • Read-only first. A caseworker writing into somebody's record is a second design.
  • Every access logged and visible to the owner, not only to the operator. "Who looked at
    my file, and when" is the question this feature creates and must answer.
  • Withdrawal is immediate and total, including anything cached.

Part 4 — Household or team instance

Several people on one instance sharing the reference data while keeping their searches
private: companies, listings, industries, contacts perhaps.

This is a different shape from Part 3 — not a grant from one person to another, but a pool
some records belong to instead of to a person. It is the more invasive of the two, because it
changes what an object's owner is, and OwnedModel is on everything.

  • Which models can be pooled, decided per model and never as a blanket setting. A company is
    plausible; an application never.
  • A pooled company edited by one person changes what another sees. That needs saying out
    loud, and probably an event trail.
  • Leaving: what happens to pooled records when somebody deletes their account (#217's
    cascade reasoning, with a second owner in the picture).

What both parts break, and must be answered before either is built

  • for_user() becomes visible_to(), with the grant consulted. Every queryset in the
    application goes through it, and the ones that do not are the bugs.
  • 404-not-403 stays. An object a person has no grant for should still be a 404 — the
    current rule is right and gets more important, because a 403 would confirm the record
    exists.
  • tests/security/ needs a whole new isolation sweep, which is exactly what #232 says is
    missing today. #232 should land first; building sharing on top of an untested isolation
    boundary is the wrong order.
  • Plugins assume ownership scoping. docs/PLUGINS.md and the plugin API tell authors
    their data is one person's. A grant model changes what a plugin may be handed, and #229 is
    already reworking that surface.
  • Export and backup (#181, #234, #242): whose data is in a shared record, and what does a
    person's own export contain when some of it is pooled?
  • Deletion (#217): the cascade reasoning was written for records with one owner.

Suggested order

  1. Part 1 and Part 2, in either order. Neither touches the promise, both are wanted on any
    instance with more than one person, and both could be done well before 1.0.0 if they turn
    out to be wanted sooner.
  2. #232, because the isolation sweep is the floor everything below stands on.
  3. The promise decision and the grant primitive, once.
  4. Part 3, then Part 4 — Part 3 is read-only and reversible, Part 4 changes what ownership
    means.

Parts 1 and 2 may well deserve moving to an earlier milestone once someone starts; they are
here because this issue is one conversation, not because they need to wait.

Postulo is already multi-user, and more completely than this issue's title suggests. What is missing is four things, filed together because two of them require amending a promise stated in the README, and that amendment should be made once rather than twice. ## What exists, so that none of it is rebuilt - `Invite`: one signup, optionally bound to one address, expiring on its own. An instance is invite-only unless the operator opens registration deliberately (`config/settings/base.py:347`). - *Server settings → People*: every account, with role — administrator, member, deactivated — last sign-in, and per-person actions for username, plugins, recovery and deletion. - The page states the boundary in its own subtitle: *"Administrators see accounts, never anyone's applications or documents."* - `OwnedModel` and `for_user()` on every query; `OwnedObjectMixin` makes a foreign object a 404 rather than a 403. - OIDC, social sign-in and MFA, each with its own signup policy. Administration is not the gap. The gap is what one account may consume, how an operator starts, and whether anything may ever be shared. --- ## Part 1 — Per-person limits and fair use **Nothing caps what one account consumes.** There is no quota anywhere in the tree: not for documents, not for logos, not for exports, not for captures per person. On a single-person instance that is correct and should stay the default. On an instance with a dozen people it means one account can fill the disk and the first anybody knows is that *everybody's* uploads start failing — and on SQLite, that a write fails at an unpredictable place. What it needs: - a per-person storage figure that can be computed without walking the disk on every page load, and shown to the person themselves as well as to the operator; - a ceiling, off by default, set per instance and optionally per person; - **the capture allowance divided rather than shared.** `POSTULO_CAPTURE_RATE` is already spent against the owner's account (#194), so this is closer than the rest — what is missing is an instance-wide view of it; - a refusal that is a sentence, not a 500: somebody at their ceiling should be told, on the page, with what to delete; - the operator's view in *Server settings → People*, which already has the column space. Depends on nothing. Could be built first and independently of everything below. ## Part 2 — First-run setup for an instance Bringing up an instance today means environment variables, a `createsuperuser`, and then finding the right pages in *Server settings*. Each step is documented; nothing walks anybody through them, and the order is only obvious to somebody who already knows it. What it needs: - a first-run path that runs when there is no account yet: create the first administrator, choose invite-only or open registration, set the instance's name and default language, test mail, issue the first invitation; - it must be **unreachable once an account exists** — a setup page that stays open is an account-creation hole, and this is the one part of this issue with a sharp security edge; - honest about what can wait: mail can be configured later, and saying so is better than a wizard that will not let somebody in without an SMTP server; - reachable in the container on first boot without a shell. Depends on nothing. Good candidate to land beside Part 1. --- ## Parts 3 and 4 need one decision made first The README promises **one person's data never reaches another's**. `for_user()` is not a convention that implements that promise — it *is* the promise, expressed as the only authorisation model the application has. Both of the parts below require an object to be legitimately visible to somebody who does not own it. That cannot be added twice, in two shapes, by two issues. **The primitive is one thing: an explicit, scoped, revocable grant, visible to the person who gave it.** The promise it becomes, and this wording should be settled here before any code: > No one sees another person's data unless that person granted it — explicitly, for a stated > scope, revocably, and visibly to them at any time. That is still a strong promise. It is a different one, and the README, `docs/THREAT-MODEL.md` and the wiki all state the current version. ### Part 3 — Shared access between people An employment-service caseworker, a coach, or an advisor seeing a job seeker's record. This is a real use of a self-hosted instance and `CompanyKind.EMPLOYMENT_SERVICE` already shows the domain is understood. - The grant is **the job seeker's to give and to withdraw**, never the operator's to assign. An administrator who could grant themselves access is the whole promise gone. - Scoped: applications and their timeline, perhaps; documents, probably not by default; the profile, no. - Read-only first. A caseworker writing into somebody's record is a second design. - **Every access logged and visible to the owner**, not only to the operator. "Who looked at my file, and when" is the question this feature creates and must answer. - Withdrawal is immediate and total, including anything cached. ### Part 4 — Household or team instance Several people on one instance sharing the reference data while keeping their searches private: companies, listings, industries, contacts perhaps. This is a *different* shape from Part 3 — not a grant from one person to another, but a pool some records belong to instead of to a person. It is the more invasive of the two, because it changes what an object's owner *is*, and `OwnedModel` is on everything. - Which models can be pooled, decided per model and never as a blanket setting. A company is plausible; an application never. - A pooled company edited by one person changes what another sees. That needs saying out loud, and probably an event trail. - Leaving: what happens to pooled records when somebody deletes their account (#217's cascade reasoning, with a second owner in the picture). --- ## What both parts break, and must be answered before either is built - **`for_user()` becomes `visible_to()`**, with the grant consulted. Every queryset in the application goes through it, and the ones that do not are the bugs. - **404-not-403 stays.** An object a person has no grant for should still be a 404 — the current rule is right and gets *more* important, because a 403 would confirm the record exists. - **`tests/security/` needs a whole new isolation sweep**, which is exactly what #232 says is missing today. #232 should land first; building sharing on top of an untested isolation boundary is the wrong order. - **Plugins assume ownership scoping.** `docs/PLUGINS.md` and the plugin API tell authors their data is one person's. A grant model changes what a plugin may be handed, and #229 is already reworking that surface. - **Export and backup** (#181, #234, #242): whose data is in a shared record, and what does a person's own export contain when some of it is pooled? - **Deletion** (#217): the cascade reasoning was written for records with one owner. ## Suggested order 1. Part 1 and Part 2, in either order. Neither touches the promise, both are wanted on any instance with more than one person, and both could be done well before 1.0.0 if they turn out to be wanted sooner. 2. #232, because the isolation sweep is the floor everything below stands on. 3. The promise decision and the grant primitive, once. 4. Part 3, then Part 4 — Part 3 is read-only and reversible, Part 4 changes what ownership means. Parts 1 and 2 may well deserve moving to an earlier milestone once someone starts; they are here because this issue is one conversation, not because they need to wait.
tiagoagueda added this to the 1.0.0 milestone 2026-09-17 19:36:06 +00:00
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#268
No description provided.