Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ Each domain concept gets a dedicated file across three parallel directories. Whe

Custom admin organization lives in `website/admin/admin_site.py` (`MakeabilityLabAdminSite`). It overrides Django's default app-based grouping with workflow-based groups: Artifacts (Publications/Talks/Posters/Videos), People & News, Projects & Media, Grants & Funding, Configuration, Administration. Section order and which models go in which group are defined in `CUSTOM_GROUPS`. Update this when adding a new top-level model that should appear on the admin index.

**Admin users & permissions (#1125):** editing access is structured as personal accounts assigned to one of two declarative groups — `Editors` (PhD/staff, full content) and `Contributors` (ugrads/interns, submit-and-review, no deletes) — plus superuser (maintainer + a break-glass backup). Grant, Award, and all account-administration models are superuser-only. The groups' permission sets are the source of truth in `setup_admin_groups` (run on every container start via `docker-entrypoint.sh`, pinned by `test_setup_admin_groups`); group *membership* is managed in `/admin`. When adding a new model that editors should manage, add it to `EDITORS_MODELS`/`CONTRIBUTORS_SPEC` and update the test. Full reference + onboarding/offboarding runbook: `docs/ADMIN_USERS_AND_GROUPS.md`.

### Key model relationships

- A `Publication` is the central artifact. `Talk`, `Poster`, `Video` are related artifacts; the admin tip is to start from the Publication's edit page so shared fields (title, authors, date, venue) auto-fill on the children.
Expand Down
5 changes: 5 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ echo "4.9 Running 'python manage.py propagate_publication_projects' to link talk
echo "******************************************"
python manage.py propagate_publication_projects

echo "****************** STEP 4.10/5: docker-entrypoint.sh ************************"
echo "4.10 Running 'python manage.py setup_admin_groups' to create/refresh the Editors and Contributors admin groups (#1125)"
echo "******************************************"
python manage.py setup_admin_groups

# echo "****************** STEP 4.3/5: docker-entrypoint.sh ************************"
# echo "4.3 Running 'python manage.py rename_person_images' to rename person images"
# echo "******************************************"
Expand Down
95 changes: 95 additions & 0 deletions docs/ADMIN_USERS_AND_GROUPS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Admin users, groups & permissions (#1125)

How editing access to the Django admin (`/admin`) is structured for the
Makeability Lab site, and the runbook for onboarding/offboarding people.

## Principle: Users = people, Groups = roles

Historically the site used a few shared, role-named *user* accounts
(`gradmin`, `ugradmin`, `collabmin`) as if they were groups — everyone in a
role shared one login. That cost us per-person attribution in the admin history,
clean offboarding (revoking one person meant rotating a shared password), and
least-privilege scoping.

Going forward: **each person gets their own account**, and **Groups carry the
permissions**. A person's access is determined by which group(s) they're in.

## The tiers

| Tier | Who | Account | How access is granted |
|---|---|---|---|
| **Superuser** | The maintainer (Jon) + one locked **break-glass** backup | personal | `is_superuser` flag (bypasses all permission checks) |
| **Editors** | PhD students & long-term staff who maintain the site | personal, one each | member of the `Editors` group |
| **Contributors** | Undergrads / interns | shared `contributor`, or personal if they become a regular maintainer | member of the `Contributors` group |

Why two superusers: never rely on exactly one. The break-glass account has a
strong, unique password and is used only to recover if the primary account is
locked out or broken.

Why account creation stays superuser-only: in Django's default admin, anyone who
can add/change `User` or `Group` objects can grant themselves permissions — an
escalation path. So neither group gets any `auth.*` permission; **only a
superuser creates accounts and assigns groups.**

## Permission sets

These are defined declaratively in
`website/management/commands/setup_admin_groups.py` and pinned by
`website/tests/test_setup_admin_groups.py`.

**`Editors`** — full `add`/`change`/`delete`/`view` on the public content models:
`banner, person, position, project, keyword, talk, publication, poster, news,
video, photo, projectumbrella, sponsor, projectrole`.

**`Contributors`** — submit-and-review, never destroy:
- `person`: `add`, `change`, `view` (edit bios)
- `publication`, `talk`, `poster`, `projectrole`: `add` + `view` (create their
own work and review it; `view` is required so the admin changelist is
reachable after an add)
- **no `delete` anywhere**

### Deliberately admin-only (neither group)

- **`Grant`** (Grants & Funding — funding data) and **`Award`** (curated external
recognitions). Note: *paper* awards live on `Publication.award`, which Editors
*can* edit via the publication; only the standalone `Award` model is withheld.
- `User`, `Group`, `Permission`, `LogEntry`, sessions — account/audit administration.

## How it's enforced

`setup_admin_groups` is **idempotent and the source of truth** for these two
groups' *permissions*: each run calls `Group.permissions.set(...)`, so a
permission added or removed by hand in the admin is reverted on the next run.

It runs automatically on every container start via `docker-entrypoint.sh`
(step 4.10) — this is the only push-deploy-compatible path because the test/prod
servers have no shell access. Edit the spec → redeploy to change a group's
permissions; don't hand-edit them in `/admin` (the change won't survive).

**Group *membership* is NOT managed by the command** — who belongs to a group is
set in `/admin → Users` and persists across deploys. Likewise users, the
superuser flag, and any other groups are untouched.

To preview without writing: `python manage.py setup_admin_groups --dry-run`.

## Runbook

**Onboard a PhD student / long-term editor**
1. `/admin → Users → Add`: create their account (they set their own password).
2. Set `is_staff = True`. Do **not** set `is_superuser`.
3. Add them to the `Editors` group. Save.

**Onboard an undergrad / intern**
- Either add them to the shared `contributor` account's owners, or (preferred for
anyone staying a while) create a personal account in the `Contributors` group.
- Promote to `Editors` if they become a regular site maintainer.

**Offboard anyone**
- `/admin → Users`: set `is_active = False`. **Do not delete** the account —
deleting it orphans their admin-history (`LogEntry`) attribution. Deactivating
blocks login while preserving the record of what they changed.

**Rotate**
- The legacy shared accounts (`gradmin`/`ugradmin`/`collabmin`/old `makeadmin`)
should be deactivated, and any retained superuser password reset, since their
hashes exist in old DB dumps.
150 changes: 150 additions & 0 deletions website/management/commands/setup_admin_groups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import logging
from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType

# This retrieves a Python logging instance (or creates it)
_logger = logging.getLogger(__name__)

# The four auto-generated Django model permissions (add/change/delete/view).
CRUD = ("add", "change", "delete", "view")

# ---------------------------------------------------------------------------
# Permission specs (issue #1125 — "Users = people, Groups = roles").
#
# We deliberately stopped using shared, role-named *user* accounts (gradmin,
# ugradmin, collabmin) as if they were groups. Instead, individuals get personal
# accounts assigned to one of these two groups; the superuser flag (you, plus a
# break-glass backup) covers everything a group cannot.
#
# Each spec maps (app_label, model_name) -> tuple of actions to grant.
# ---------------------------------------------------------------------------

# Editors — PhD students and long-term staff who maintain the site. Full content
# management on the public-facing models. DELIBERATELY EXCLUDES:
# - grant, award -> admin-only by decision (funding data / curated
# external recognitions stay with the superuser)
# - user/group/permission/logentry/session -> no account administration
# outside the superuser
# - contenttype, easy_thumbnails.* -> infra/cache tables nobody hand-edits
# (these were collateral on the old gradmin account)
EDITORS_MODELS = [
"banner", "person", "position", "project", "keyword", "talk",
"publication", "poster", "news", "video",
"photo", "projectumbrella", "sponsor", "projectrole",
]
EDITORS_SPEC = {("website", model): CRUD for model in EDITORS_MODELS}

# Contributors — undergrads / interns (shared `contributor` account, or a personal
# account promoted to Editors if they become a regular maintainer). Narrowest
# useful tier: edit bios, and add (with view) on the main artifacts so they can
# submit their own work AND review what they submitted, but never change or delete
# anyone else's. NO deletes anywhere. This merges the two real shapes the legacy
# accounts had: ugradmin (Person add/change/view) and collabmin (add across
# artifacts), plus `view` so the changelist is reachable after an add (without it
# Django bounces them to the admin index with no way to see their own entry).
CONTRIBUTORS_SPEC = {
("website", "person"): ("add", "change", "view"),
("website", "publication"): ("add", "view"),
("website", "talk"): ("add", "view"),
("website", "poster"): ("add", "view"),
("website", "projectrole"): ("add", "view"),
}

GROUPS = {
"Editors": EDITORS_SPEC,
"Contributors": CONTRIBUTORS_SPEC,
}


class Command(BaseCommand):
help = (
"Create/refresh the Editors and Contributors admin groups with their "
"intended permission sets (issue #1125). Idempotent: each run sets each "
"group's permissions to exactly the spec below — extra permissions added "
"by hand are REMOVED and missing ones are added, so this command is the "
"source of truth for these two groups. It does NOT create user accounts, "
"assign users to groups, or touch the superuser flag; do that in /admin. "
"Run with --dry-run first to preview changes."
)

def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Report what would change without writing to the database.",
)

def _resolve_perms(self, spec):
"""Turn a {(app_label, model): (actions,)} spec into a set of Permission
objects, warning (not failing) on any codename/content type that can't be
found so a single typo or missing model never aborts the whole run."""
perms = set()
for (app_label, model_name), actions in spec.items():
try:
content_type = ContentType.objects.get(
app_label=app_label, model=model_name
)
except ContentType.DoesNotExist:
_logger.warning(
f"setup_admin_groups: no content type for "
f"{app_label}.{model_name} — skipping (is the model migrated?)"
)
continue
for action in actions:
codename = f"{action}_{model_name}"
try:
perms.add(
Permission.objects.get(
content_type=content_type, codename=codename
)
)
except Permission.DoesNotExist:
_logger.warning(
f"setup_admin_groups: no permission '{codename}' for "
f"{app_label}.{model_name} — skipping."
)
return perms

def handle(self, *args, **options):
dry_run = options["dry_run"]
_logger.debug(f"Running setup_admin_groups.py (dry_run={dry_run})")

for group_name, spec in GROUPS.items():
desired = self._resolve_perms(spec)

if dry_run:
group = Group.objects.filter(name=group_name).first()
current = set(group.permissions.all()) if group else set()
else:
group, created = Group.objects.get_or_create(name=group_name)
if created:
_logger.info(f"Created group '{group_name}'")
current = set(group.permissions.all())

to_add = desired - current
to_remove = current - desired

for perm in sorted(to_add, key=lambda p: p.codename):
_logger.info(
f"[{'dry-run' if dry_run else 'apply'}] {group_name}: "
f"+ {perm.codename}"
)
for perm in sorted(to_remove, key=lambda p: p.codename):
_logger.info(
f"[{'dry-run' if dry_run else 'apply'}] {group_name}: "
f"- {perm.codename}"
)

if not dry_run:
# set() makes the group match the spec exactly (idempotent).
group.permissions.set(desired)

verb = "Would set" if dry_run else "Set"
_logger.info(
f"setup_admin_groups: {verb} '{group_name}' to "
f"{len(desired)} permission(s) "
f"(+{len(to_add)} / -{len(to_remove)})."
)

_logger.debug("Completed setup_admin_groups.py")
Loading
Loading