Companies, People and Listings owned by a user or a group, and shared read/write or read-only #896

Closed
opened 2026-10-12 10:12:27 +00:00 by tiagoagueda · 5 comments
Owner

Companies, People (contacts) and Listings can be owned by a user or by a group, and shared with other users or groups, either read/write or read-only. Everything a person writes about them stays their own: company notes, applications and the rest remain user-owned and are never shared by this.

This is the concrete form of #268 Part 4 (household or team instance), with Part 3's explicit grant used as the sharing primitive. It amends the README promise, so #268's wording question has to be settled first.

What exists today

  • OwnedModel (src/postulo/core/models.py:56) gives every record a single owner user, and for_user() is the only authorisation model. OwnedObjectMixin makes a foreign record a 404.
  • Company's docstring states the opposite of this feature on purpose: "Companies are owner-scoped rather than shared. Two people … each keep their own notes on the same employer." (src/postulo/jobs/models.py:304)
  • Groups already exist: Django's auth.Group, managed under Server settings → Groups (#886, core/views_access.py), and used today only by plugin access rules.
  • Notes are columns on the shared candidates: Company.notes and Contact.notes.

What it needs

Ownership

  • A Company, Contact or JobPosting is owned by exactly one user or group. A group-owned record belongs to the group, not to whoever created it.
  • Decided (2026-10-12): who may delete a record or change its owner.
    • Group-owned: every member of the owning group.
    • User-owned: only that user.
    • Write access is not ownership. Someone with read/write access, whether through a share or through a group the record is only shared with, can change the record's details. They cannot delete it or change its owner.
    • Superusers can make any change to any company, contact or listing, including deleting it or changing its owner, without owning it. This includes records a user keeps private and has shared with no one, so the amended promise in the README and docs/THREAT-MODEL.md must say so. Confirmed (2026-10-12): superusers have no access to personal notes or applications, nor to anything else that stays user-owned. Server settings → People keeps its promise for those.
  • Moving a record from a user to a group, and back, is an explicit action that says what it does.

Sharing

  • The owner can grant a user or a group read-only or read/write access. Every grant is listed on the record, visible to the owner, and revocable; revocation is immediate.
  • Read/write means editing the record's own fields. It does not include deleting it, changing its owner or resharing it (see above).
  • A record nobody has granted you stays a 404, not a 403.

What stays per user

  • Company notes and contact notes move off the shared row into a per-user note (one per person and record), with a data migration that carries every current note to its owner. A shared company shows each person only their own note.
  • Applications, reminders, interviews, appointments, offers, suggestions, documents, captures, tags and everything else stay OwnedModel as they are. An application of mine on a shared listing is still mine alone, and nobody the listing is shared with learns that it exists.
  • Listing status is per user. Decided (2026-10-12). JobPosting.state and what goes with it (discard_reason, noted_at, decided_at) move off the shared row into a per-user record, with a migration that gives each current value to the listing's owner. One person discarding a shared listing does not discard it for anyone else. Facts about the posting itself stay on the record: closes_at, closing_announced_for and closed_at say whether the opening is still available, which is the same for everyone.
  • A listing's history stays per user. Decided (2026-10-12). Sharing a listing shares the listing itself, not its history (ListingEvent, #270). The use is two people applying in parallel for the same position, each from their own side: each keeps their own messages, captures and notes about it, and their own application.
  • Any other per-user state that comes up is decided field by field and written down here.

Records hanging off a shared one

  • Departments, identifiers, logos, addresses, phone numbers, web links and messaging handles of a shared company or contact follow its access. They are part of it, not separate grants.
  • References across visibility. Decided (2026-10-12). Examples: a shared listing whose company is not shared, a shared contact at a private company, a company whose parent is private. When someone shares a record that points at one the recipient cannot see, a prompt asks whether to share that one too, and with what scope (read-only or read/write). The scope defaults to the one just chosen.
    • The prompt offers only records the sharer is allowed to share, meaning ones they own or, for group-owned ones, whose group they belong to.
    • No share leaves a hidden reference behind. Decided (2026-10-12). If the sharer declines to share a linked record, or is not allowed to, the whole share is refused, and the prompt names which record blocks it and why. A recipient therefore never holds a record that points at one they cannot see, and no placeholder is needed.
    • The same holds afterwards. Linking a shared record to a private one (for example, changing a shared listing's company), or revoking a share that another shared record depends on, raises the same prompt or refusal.
  • Industries and tags on a shared record: whose are shown?

Everything #268 lists as broken by any sharing

  • for_user() becomes visible_to(user, *, write=False) for these three models and their children, and every queryset, form choice, API endpoint, search group, map, calendar feed, export and plugin hook goes through it. One that does not is a leak.
  • Duplicates, merging and other conflicts. Decided (2026-10-12): they are resolved on a dedicated conflicts page, by an owner of the records involved (for a group-owned record, any member) or by an administrator. Examples: two people's own copies of the same employer, or a shared record pointing at one that is not shared. Nothing merges silently or on its own. The page lists each conflict, what each side holds, and the possible resolutions: merge into one, keep both, change the owner. The existing company Merge action, used across owners, sends the person to this page.
  • Account deletion (#217): a user-owned shared record does whatever deletion already does, and the people it was shared with are told.
  • Leaving a group. Decided (2026-10-12): option A, a private copy when they leave (options in the comment below). The group keeps its records and the person loses access straight away, whether they leave or an administrator removes them. Each group-owned record their own data points at (listings with their applications, companies and contacts with their notes) is copied to them as a user-owned record marked Copied from on . Their applications, notes and the records hanging off them are repointed to the copy. Nothing else of the group's goes with them. If they rejoin, copies and originals show up as duplicates on the conflicts page.
    • The last member cannot leave a group that still owns records, and such a group cannot be deleted. Its records are first given to a user or another group, or deleted, on the conflicts page.
    • Shares the leaver gave the group from their own records end on their own.
  • Export, import and backup (#181, #234, #242). Decided (2026-10-12): any member of the owning group can export a group-owned record. A person's export holds what they own, the group-owned records of their groups, and their own notes. Importing into a group still needs a rule.
  • API and plugins (#229, #820): tokens see what their owner sees, nothing more. Plugins that assume one owner per record (DAV, Paperless, MCP, the browser extensions) need the new rule documented in docs/PLUGINS.md.
  • Event trail: a shared record edited by someone else changes what the owner sees, so who changed what must be visible on the record.
  • tests/security/ isolation sweep (#232) extended to grants: unshared, read-only, read/write and group membership, through every surface.

Out of scope

  • Sharing applications, documents or the profile. That is #268 Part 3, a different grant.
  • Instance-wide public records.

Acceptance

  • A user can create a company owned by a group, and every member sees and edits it.
  • A user can share a contact read-only with another user, who sees it and cannot save a change. Revoking the share turns the contact into a 404 for them.
  • Two people on one shared company each see only their own note.
  • An application on a shared listing is invisible to everyone but its owner.
  • The README, docs/THREAT-MODEL.md and the wiki state the amended promise.
Companies, People (contacts) and Listings can be **owned by a user or by a group**, and **shared with other users or groups**, either read/write or read-only. Everything a person writes *about* them stays their own: company notes, applications and the rest remain user-owned and are never shared by this. This is the concrete form of #268 Part 4 (household or team instance), with Part 3's explicit grant used as the sharing primitive. It amends the README promise, so #268's wording question has to be settled first. ## What exists today - `OwnedModel` (`src/postulo/core/models.py:56`) gives every record a single `owner` user, and `for_user()` is the only authorisation model. `OwnedObjectMixin` makes a foreign record a 404. - `Company`'s docstring states the opposite of this feature on purpose: *"Companies are owner-scoped rather than shared. Two people … each keep their own notes on the same employer."* (`src/postulo/jobs/models.py:304`) - Groups already exist: Django's `auth.Group`, managed under *Server settings → Groups* (#886, `core/views_access.py`), and used today only by plugin access rules. - Notes are columns on the shared candidates: `Company.notes` and `Contact.notes`. ## What it needs ### Ownership - A Company, Contact or JobPosting is owned by **exactly one** user or group. A group-owned record belongs to the group, not to whoever created it. - **Decided (2026-10-12): who may delete a record or change its owner.** - **Group-owned:** every member of the owning group. - **User-owned:** only that user. - **Write access is not ownership.** Someone with read/write access, whether through a share or through a group the record is only *shared* with, can change the record's details. They cannot delete it or change its owner. - **Superusers** can make any change to any company, contact or listing, including deleting it or changing its owner, without owning it. This includes records a user keeps private and has shared with no one, so the amended promise in the README and `docs/THREAT-MODEL.md` must say so. **Confirmed (2026-10-12): superusers have no access to personal notes or applications**, nor to anything else that stays user-owned. *Server settings → People* keeps its promise for those. - Moving a record from a user to a group, and back, is an explicit action that says what it does. ### Sharing - The owner can grant a user or a group **read-only** or **read/write** access. Every grant is listed on the record, visible to the owner, and revocable; revocation is immediate. - Read/write means editing the record's own fields. It does not include deleting it, changing its owner or resharing it (see above). - A record nobody has granted you stays a 404, not a 403. ### What stays per user - **Company notes and contact notes** move off the shared row into a per-user note (one per person and record), with a data migration that carries every current note to its owner. A shared company shows each person only their own note. - **Applications, reminders, interviews, appointments, offers, suggestions, documents, captures, tags** and everything else stay `OwnedModel` as they are. An application of mine on a shared listing is still mine alone, and nobody the listing is shared with learns that it exists. - **Listing status is per user. Decided (2026-10-12).** `JobPosting.state` and what goes with it (`discard_reason`, `noted_at`, `decided_at`) move off the shared row into a per-user record, with a migration that gives each current value to the listing's owner. One person discarding a shared listing does not discard it for anyone else. Facts about the posting itself stay on the record: `closes_at`, `closing_announced_for` and `closed_at` say whether the opening is still available, which is the same for everyone. - **A listing's history stays per user. Decided (2026-10-12).** Sharing a listing shares the listing itself, not its history (`ListingEvent`, #270). The use is two people applying in parallel for the same position, each from their own side: each keeps their own messages, captures and notes about it, and their own application. - Any other per-user state that comes up is decided field by field and written down here. ### Records hanging off a shared one - Departments, identifiers, logos, addresses, phone numbers, web links and messaging handles of a shared company or contact follow its access. They are part of it, not separate grants. - **References across visibility. Decided (2026-10-12).** Examples: a shared listing whose company is not shared, a shared contact at a private company, a company whose parent is private. When someone shares a record that points at one the recipient cannot see, a prompt asks whether to share that one too, and with what scope (read-only or read/write). The scope defaults to the one just chosen. - The prompt offers only records the sharer is allowed to share, meaning ones they own or, for group-owned ones, whose group they belong to. - **No share leaves a hidden reference behind. Decided (2026-10-12).** If the sharer declines to share a linked record, or is not allowed to, the whole share is refused, and the prompt names which record blocks it and why. A recipient therefore never holds a record that points at one they cannot see, and no placeholder is needed. - The same holds afterwards. Linking a shared record to a private one (for example, changing a shared listing's company), or revoking a share that another shared record depends on, raises the same prompt or refusal. - Industries and tags on a shared record: whose are shown? ### Everything #268 lists as broken by any sharing - `for_user()` becomes `visible_to(user, *, write=False)` for these three models and their children, and every queryset, form choice, API endpoint, search group, map, calendar feed, export and plugin hook goes through it. One that does not is a leak. - **Duplicates, merging and other conflicts. Decided (2026-10-12):** they are resolved on **a dedicated conflicts page**, by an owner of the records involved (for a group-owned record, any member) or by an administrator. Examples: two people's own copies of the same employer, or a shared record pointing at one that is not shared. Nothing merges silently or on its own. The page lists each conflict, what each side holds, and the possible resolutions: merge into one, keep both, change the owner. The existing company Merge action, used across owners, sends the person to this page. - **Account deletion** (#217): a user-owned shared record does whatever deletion already does, and the people it was shared with are told. - **Leaving a group. Decided (2026-10-12): option A, a private copy when they leave** (options in the comment below). The group keeps its records and the person loses access straight away, whether they leave or an administrator removes them. Each group-owned record their own data points at (listings with their applications, companies and contacts with their notes) is copied to them as a user-owned record marked *Copied from <group> on <date>*. Their applications, notes and the records hanging off them are repointed to the copy. Nothing else of the group's goes with them. If they rejoin, copies and originals show up as duplicates on the conflicts page. - The last member cannot leave a group that still owns records, and such a group cannot be deleted. Its records are first given to a user or another group, or deleted, on the conflicts page. - Shares the leaver gave the group from their own records end on their own. - **Export, import and backup** (#181, #234, #242). **Decided (2026-10-12):** any member of the owning group can export a group-owned record. A person's export holds what they own, the group-owned records of their groups, and their own notes. Importing into a group still needs a rule. - **API and plugins** (#229, #820): tokens see what their owner sees, nothing more. Plugins that assume one owner per record (DAV, Paperless, MCP, the browser extensions) need the new rule documented in `docs/PLUGINS.md`. - **Event trail:** a shared record edited by someone else changes what the owner sees, so who changed what must be visible on the record. - `tests/security/` isolation sweep (#232) extended to grants: unshared, read-only, read/write and group membership, through every surface. ## Out of scope - Sharing applications, documents or the profile. That is #268 Part 3, a different grant. - Instance-wide public records. ## Acceptance - A user can create a company owned by a group, and every member sees and edits it. - A user can share a contact read-only with another user, who sees it and cannot save a change. Revoking the share turns the contact into a 404 for them. - Two people on one shared company each see only their own note. - An application on a shared listing is invisible to everyone but its owner. - The README, `docs/THREAT-MODEL.md` and the wiki state the amended promise.
Author
Owner

Leaving a group: options (undecided)

Group-owned records stay with the group. The person who leaves loses access straight away. This holds whether they leave themselves or an administrator removes them on Server settings → Groups. The hard part is that their own data still points at records they can no longer see:

  • their applications on group-owned listings;
  • their notes on group-owned companies and contacts;
  • their reminders, interviews and offers hanging off those applications.

A 404 on their own application's listing is not acceptable. These are the ways to avoid it:

A. A private copy when they leave (recommended). Every group-owned record their own data points at is copied to them as a user-owned record, marked Copied from on . Their applications and notes are repointed to the copy. Nothing else of the group's goes with them. They keep a working record of their own search, and the group loses nothing. If they rejoin, the copies and the originals appear as duplicates on the conflicts page, where they can be merged.

B. Read-only access to what they used. They keep read-only access to exactly the records their own data points at, for as long as that data exists. Nothing is duplicated. But the group's later edits keep reaching someone who has left, and "who can see this" stops being answered by the grant list alone.

C. A frozen view. Their application shows the listing and company as they were when they left, stored on the application itself, with no live record behind it. Simplest to reason about. It is a third way of holding a company, though, and it cannot be edited.

D. Settle it before leaving. Leaving is refused until they have settled each such record on the conflicts page: take a copy, let it go, or ask the group to transfer it. This is complete, but an administrator removing someone cannot wait for them, so D still needs A or B as the default.

Whichever option is chosen, two further rules:

  • The last member cannot leave a group that still owns records, and a group that owns records cannot be deleted. Its records must first be given to a user or another group, or deleted, on the conflicts page. A record is never left with no owner.
  • Shares the leaver gave from their own records to the group end on their own, because they are no longer a member. Nothing changes on the records.

Suggested: A as the default, with D's page offered beforehand when the person leaves by themselves, so they can let go of what they do not want to keep instead of collecting copies.

## Leaving a group: options (undecided) Group-owned records stay with the group. The person who leaves loses access straight away. This holds whether they leave themselves or an administrator removes them on *Server settings → Groups*. The hard part is that their **own** data still points at records they can no longer see: - their applications on group-owned listings; - their notes on group-owned companies and contacts; - their reminders, interviews and offers hanging off those applications. A 404 on their own application's listing is not acceptable. These are the ways to avoid it: **A. A private copy when they leave (recommended).** Every group-owned record their own data points at is copied to them as a user-owned record, marked *Copied from <group> on <date>*. Their applications and notes are repointed to the copy. Nothing else of the group's goes with them. They keep a working record of their own search, and the group loses nothing. If they rejoin, the copies and the originals appear as duplicates on the conflicts page, where they can be merged. **B. Read-only access to what they used.** They keep read-only access to exactly the records their own data points at, for as long as that data exists. Nothing is duplicated. But the group's later edits keep reaching someone who has left, and "who can see this" stops being answered by the grant list alone. **C. A frozen view.** Their application shows the listing and company as they were when they left, stored on the application itself, with no live record behind it. Simplest to reason about. It is a third way of holding a company, though, and it cannot be edited. **D. Settle it before leaving.** Leaving is refused until they have settled each such record on the conflicts page: take a copy, let it go, or ask the group to transfer it. This is complete, but an administrator removing someone cannot wait for them, so D still needs A or B as the default. Whichever option is chosen, two further rules: - **The last member cannot leave a group that still owns records**, and a group that owns records cannot be deleted. Its records must first be given to a user or another group, or deleted, on the conflicts page. A record is never left with no owner. - **Shares the leaver gave from their own records to the group** end on their own, because they are no longer a member. Nothing changes on the records. Suggested: **A as the default, with D's page offered beforehand** when the person leaves by themselves, so they can let go of what they do not want to keep instead of collecting copies.
Author
Owner

Decided 2026-10-12: leaving a group follows option A (a private copy when they leave); the body now says so. Superusers have no access to personal notes or applications; also recorded in the body.

Decided 2026-10-12: leaving a group follows **option A** (a private copy when they leave); the body now says so. Superusers have no access to personal notes or applications; also recorded in the body.
Author
Owner

Decided 2026-10-12: listing status is per user. When a shared record points at a private one, a prompt asks whether to share that one too, and with which scope, unless the sharer cannot share it. Both are recorded in the body. What the recipient sees when the private record stays hidden (a placeholder) is assumed and marked to confirm.

Decided 2026-10-12: listing status is per user. When a shared record points at a private one, a prompt asks whether to share that one too, and with which scope, unless the sharer cannot share it. Both are recorded in the body. What the recipient sees when the private record stays hidden (a placeholder) is assumed and marked to confirm.
Author
Owner

Decided 2026-10-12: a share that would point at a record the recipient cannot see is refused altogether, with no placeholder. Sharing a listing shares only the listing, not its history, so two people can apply in parallel for the same position, each with their own history and application. Both are recorded in the body.

Decided 2026-10-12: a share that would point at a record the recipient cannot see is refused altogether, with no placeholder. Sharing a listing shares only the listing, not its history, so two people can apply in parallel for the same position, each with their own history and application. Both are recorded in the body.
Author
Owner

Decided 2026-10-13, before implementation starts:

  • Industries on a shared record are part of the record: everyone with access sees them; anyone with write access can add one from their own vocabulary or remove one. The sector follows for everyone.
  • Importing into a group: the import page offers the owner (me, or one of my groups), defaulting to me. Matching against existing records (name, Wikidata id, address) only looks at that owner's records.
  • Losing access to a record your own data points at is always a copy, not only when leaving a group: also when a share is revoked, a group record is moved to one person, or the owner deletes the shared record or their account. The records the person's applications, notes and the rest point at are copied to them (Copied from on ), their data is repointed, and they are told. Nobody loses their own applications because of someone else's action.
  • Export: records only shared with the person, which their own data points at, go into their archive as shared from snapshots, so an archive is self-contained.
  • The amended promise (settled on #268): 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. Superusers can see and change any company, contact or listing, including private ones, but never anyone's notes, applications or documents.
  • Delivery: built on the branch 896-sharing and landed as one commit.
Decided 2026-10-13, before implementation starts: - **Industries on a shared record** are part of the record: everyone with access sees them; anyone with write access can add one from their own vocabulary or remove one. The sector follows for everyone. - **Importing into a group:** the import page offers the owner (me, or one of my groups), defaulting to me. Matching against existing records (name, Wikidata id, address) only looks at that owner's records. - **Losing access to a record your own data points at is always a copy**, not only when leaving a group: also when a share is revoked, a group record is moved to one person, or the owner deletes the shared record or their account. The records the person's applications, notes and the rest point at are copied to them (*Copied from <owner> on <date>*), their data is repointed, and they are told. Nobody loses their own applications because of someone else's action. - **Export:** records only shared with the person, which their own data points at, go into their archive as *shared from <owner>* snapshots, so an archive is self-contained. - **The amended promise** (settled on #268): *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. Superusers can see and change any company, contact or listing, including private ones, but never anyone's notes, applications or documents.* - **Delivery:** built on the branch `896-sharing` and landed as one commit.
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#896
No description provided.