forked from retoor/devplacepy
feat: restrict backup archive download to primary admin and hide admin-hidden projects from other admins
- Add `get_admin_uids()` and `get_primary_admin_uid()` to database.py for resolving the earliest-created admin - Modify `can_view_project()` in content.py so a project hidden by an admin is invisible to other admins (both web UI and REST API) - Update `_download_url()` and `_backup_payload()` in admin/backups.py to accept a `can_download` flag, gating the download endpoint with `is_primary_admin()` - Remove `role` from `_user_facts()` in docs_live.py to avoid leaking admin status in live docs - Update doc summaries in docs_api.py to reflect the new admin-visibility and backup-download semantics
This commit is contained in:
@@ -101,7 +101,7 @@ Routers in `devplacepy/routers/` are organised as a **directory tree that mirror
|
||||
| `/votes` | votes.py |
|
||||
| `/avatar` | avatar.py |
|
||||
| `/follow` | follow.py |
|
||||
| `/admin` | admin/ package - one leaf per sub-resource (`index`, `users`, `aiusage`, `aiquota`, `media`, `trash`, `settings`, `notifications`, `news`, `auditlog`, `backups`) plus the folded-in `services.py` and `containers.py` (mounted with `/services` and `/containers` sub-prefixes). `main.py` mounts the whole `/admin` tree from this one package. The `backups` leaf is the admin **Backups** dashboard (`BackupService`, kind `backup`): storage usage, backup archives, and interval/cron backup schedules (CRUD + rotation). See AGENTS.md -> "Backup service" |
|
||||
| `/admin` | admin/ package - one leaf per sub-resource (`index`, `users`, `aiusage`, `aiquota`, `media`, `trash`, `settings`, `notifications`, `news`, `auditlog`, `backups`) plus the folded-in `services.py` and `containers.py` (mounted with `/services` and `/containers` sub-prefixes). `main.py` mounts the whole `/admin` tree from this one package. The `backups` leaf is the admin **Backups** dashboard (`BackupService`, kind `backup`): storage usage, backup archives, and interval/cron backup schedules (CRUD + rotation). **Archive download is restricted to the primary administrator** (the earliest-created Admin, resolved by `database.get_primary_admin_uid` / `utils.is_primary_admin`): `GET /admin/backups/{uid}/download` 403s every other admin, the `download_url` field is withheld from them at every endpoint (`can_download = is_primary_admin(admin)`, surfaced as `BackupDashboardOut.can_download_backups`), and `BackupMonitor.js` renders their Download control as a disabled button tooltipped `Not available`. See AGENTS.md -> "Backup service" |
|
||||
| `/admin/services` | admin/services.py |
|
||||
| `/issues` | issues/ package - issue tracker backed by Gitea (no local issue store): `index.py` (list `?state=`/`?page=`, detail `/{number}` with comments), `create.py` (async AI-enhanced filing `/create` enqueues a `issue_create` job, status at `/jobs/{uid}`), `comment.py` (synchronous, pushes to Gitea + notifies admins), `status.py` (admin open/closed) |
|
||||
| `/gists` | gists.py |
|
||||
@@ -117,7 +117,7 @@ Routers in `devplacepy/routers/` are organised as a **directory tree that mirror
|
||||
| `/p/{slug}` | proxy.py - public ingress reverse proxy (HTTP + WebSocket) to a running container instance's published host port, opt-in per instance via `ingress_slug` |
|
||||
| `/xmlrpc` | xmlrpc.py - reverse-proxies XML-RPC calls to the forking XML-RPC bridge (`services/xmlrpc/`, supervised by `XmlrpcService` on loopback `config.XMLRPC_PORT`), which generates one XML-RPC method per documented REST endpoint from `docs_api.API_GROUPS` (`posts.create`, `feed.list`, ...). One struct of named params per call; auth via in-band `api_key`, `X-API-KEY`/`Bearer` header, or `http://user:pass@host/xmlrpc` Basic. Full introspection + `system.multicall`; REST errors become XML-RPC faults. Exempt from the rate limiter (enforced on the forwarded internal hop). See AGENTS.md -> "XML-RPC bridge" |
|
||||
| `/api` | devrant/ package - devRant-compatible REST protocol (`auth.py`, `rants.py`, `comments.py`, `notifs.py`). Translates devRant requests onto DevPlace data: rants<->posts, comments/votes onto the native engagement layer, devRant integer ids onto each table's auto-increment `id`. Token auth via `devrant_tokens`. See `### devRant compatibility API` and AGENTS.md -> "devRant compatibility API" |
|
||||
| `/dbapi` | dbapi/ package - **admin/internal-only** generic database API over `dataset`. `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (`GET/POST/PATCH/DELETE /{table}[/{key}/{value}]`, soft-delete aware, born-live inserts, `?include_deleted`, `.../restore`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SQL, `execute=true` runs it read-only). Auth = admin session/api_key OR `internal_gateway_key()` (`services/dbapi/policy.py`); SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` (admin; mutations confirm-gated). See AGENTS.md -> "Database API service" |
|
||||
| `/dbapi` | dbapi/ package - **admin/internal-only, strictly READ-ONLY** generic database API over `dataset` (never inserts/updates/deletes/restores; the write surface was removed because it bypassed every per-route admin safeguard). `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (read only: `GET /{table}` + `GET /{table}/{key}/{value}`, `?include_deleted`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SELECT, `execute=true` runs it read-only). Auth = admin session/api_key OR `internal_gateway_key()` (`services/dbapi/policy.py`); SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` are read-only (list/get/query/nl; no write tools). See AGENTS.md -> "Database API service" |
|
||||
| `/pubsub` | pubsub.py - **database-free** publish/subscribe bus. `WS /pubsub/ws` (subscribe/unsubscribe/publish frames, `foo.*` wildcards), `POST /pubsub/publish` + `GET /pubsub/topics` (admin/internal). Lock-owner-gated WS (`4013` retry) so subscribers converge on one worker; topic authz in `services/pubsub/policy.py` (`user.{uid}.*` private, `public.*` shared, admin/internal anywhere, guests opt-in). In-process `services.pubsub.publish(topic, data)` for backends; frontend `app.pubsub`. See AGENTS.md -> "Pub/Sub service" |
|
||||
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
|
||||
|
||||
@@ -229,9 +229,10 @@ A second REST surface mounted at `/api` that mimics the public devRant API on De
|
||||
- **Slug + UUID lookup:** resources with slugs (posts, gists, news, projects) accept either the slug or the bare UUID - see `resolve_by_slug()` in `database.py`. Slugs are generated via `make_combined_slug(title, uid)` which prefixes the last 12 hex chars of the UUID (the uuid7 **random tail**, NOT its leading bytes - the first 48 bits are a millisecond timestamp, so a head prefix is identical for all rows written in the same ~65s window and collides with the same title, making `resolve_by_slug` ambiguous; same reasoning as the blob-shard tail rule).
|
||||
- **Username:** 3-32 chars, letters/numbers/hyphens/underscores. **Password:** min 6 chars.
|
||||
- **Roles are stored capitalized:** the `users.role` value is exactly `"Admin"` or `"Member"` (the first registered user becomes `"Admin"`; `auth.py`). Always test admin-ness through the `is_admin(user)` global (`utils.py`, also a Jinja global), which compares `user.get("role") == "Admin"` case-sensitively - never hand-roll a lowercase compare. The **only** lowercase surface is the CLI (`devplace role set <username> <member|admin>` accepts lowercase but writes `role.capitalize()`; `role get` prints `.lower()` for display). A lowercase role in the DB silently fails every admin check.
|
||||
- **Admin seniority: a junior admin cannot manage a more senior admin.** Every per-user mutation in `routers/admin/users.py` (role/password/toggle/reset-ai-quota) is gated by `_is_senior_admin(actor, target)` - it blocks (audits `result="denied"`) when the target is an Admin who registered earlier (`(created_at, id)` ascending, matching `get_primary_admin_uid()`). Members and junior admins stay manageable; the guard is server-side so it also covers Devii's admin tools. See AGENTS.md -> "Admin seniority guard".
|
||||
- **Never pass a `respond()` context key that collides with a Jinja global.** `respond(request, template, ctx, model=XOut)` feeds the **same** `ctx` dict to both the Pydantic model (JSON) and the template render. Jinja context variables shadow `env.globals` across the whole inheritance chain, so a key like `is_admin`/`avatar_url`/`format_date`/`is_self`/`owns`/`guest_disabled` holding a non-callable value turns `base.html`'s `{% if is_admin(user) %}` into `False(user)` -> `TypeError: 'bool' object is not callable`, a 500 that only fires for the branch that calls the global (e.g. logged-in users, not guests). Name viewer/permission flags distinctly (`viewer_is_admin`, not `is_admin`) in both the schema and the context. This was a real issue on `/issues/{number}`; `tests/api/issues/create.py::test_issue_detail_renders_for_{member,admin}` guard it.
|
||||
- **Avatars:** Multiavatar SVG generated locally from the username seed in <5ms, in-memory cached (cleared on restart). URL `/avatar/multiavatar/{seed}?size={size}`. Falls back to initial-based SVG on error.
|
||||
- **Project visibility (`is_private`) and read-only (`read_only`)** are owner-controlled integer flags on the `projects` row (see `AGENTS.md` -> "Project visibility and read-only"). Two invariants: (1) private-read access is gated by the single `content.can_view_project(project, user)` predicate (owner or admin) at EVERY read surface - detail, file routes (`_load_viewable_project`), zip, listing, profile, sitemap - never re-implement the check inline; (2) read-only is enforced as a single data-layer guard `project_files._guard_writable(project_uid)` at the top of every mutation entrypoint in `project_files.py`, so it covers the HTTP routes, Devii (via the routes), and container sync at once - add the guard to any NEW file-mutating function, and never enforce read-only only in a route. Devii may flip read-only or visibility (private/public) only after explicit user confirmation, enforced by `dispatcher.confirmation_error` (the `CONFIRM_REQUIRED` set) before the HTTP call; mirror that pattern for any future irreversible agent action. Both owner toggle buttons (`project_detail.html`) likewise carry `data-confirm` so the human UI gates them through `app.dialog` too. **Deletions ALWAYS require confirmation:** `confirmation_error` gates EVERY content delete tool via the `CONFIRM_REQUIRED` set - `delete_post`, `delete_comment`, `delete_gist`, `delete_project`, `project_delete_file`, `delete_media`, `delete_attachment`, `admin_delete_news` - plus `container_instance_action` with `action="delete"` and any `container_exec` whose command matches `dispatcher.DESTRUCTIVE_COMMAND` (rm/rmdir/unlink/shred/truncate/dd/mkfs/wipefs, `find -delete`, redirect over a file, SQL `drop`/`delete from`). The first call is refused with a message; the agent must show the user the exact target and only then pass `confirm=true` (the `DELETING IS ALWAYS CONFIRMED` system-prompt rule mirrors this). Any new name added to `CONFIRM_REQUIRED` is auto-confirmed by a generic fallback in `confirmation_error`. **Load-bearing: every confirmation-gated tool MUST also declare a `confirm` boolean param in its catalog spec** (use the `confirm()` helper in `catalog.py`, or `arg("confirm", ..., kind="boolean")` for container actions). Tool schemas set `additionalProperties: false`, so a gated tool WITHOUT a declared `confirm` param can never receive `confirm=true` from the model - the gate refuses every call and the agent loops forever (this was a real issue on all the delete tools). The param is harmlessly forwarded to the HTTP endpoint (the routes do not declare it; FastAPI ignores undeclared form fields) and ignored by the LOCAL container/customization controllers. The delete tools stay `requires_auth` (not `requires_admin`) - members delete their own, the endpoint's owner-or-admin guard lets admins delete any.
|
||||
- **Project visibility (`is_private`) and read-only (`read_only`)** are owner-controlled integer flags on the `projects` row (see `AGENTS.md` -> "Project visibility and read-only"). Two invariants: (1) private-read access is gated by the single `content.can_view_project(project, user)` predicate at EVERY read surface - detail, file routes (`_load_viewable_project`), zip, listing, profile, sitemap - never re-implement the check inline. The predicate is `not is_private OR is_owner OR (is_admin AND owner is not an admin)`: a project hidden by a **member** stays visible to any admin, but a project hidden by an **admin** is visible only to that owner admin - **other admins are blocked**. This extends to **containers and terminals**: every per-project container route (`routers/projects/containers/` via `project_for(slug, user)`), the exec-WS PTY terminal, the admin **Containers** manager (`routers/admin/containers.py` listing + `_viewable_instance_or_404` + create + project-search), and the Devii container tools (`ContainerController._project`) gate on `can_view_project(project_of_instance, user)`, so an admin can neither see, start, exec, nor manage a container attached to another admin's hidden project. `get_projects_list` mirrors this for the `/projects` listing (admins are filtered against `database.get_admin_uids()`). Deliberate exclusions: the opt-in public ingress `/p/{slug}` stays anonymous/public, and the generic admin raw DB API `/dbapi` is unchanged. (2) read-only is enforced as a single data-layer guard `project_files._guard_writable(project_uid)` at the top of every mutation entrypoint in `project_files.py`, so it covers the HTTP routes, Devii (via the routes), and container sync at once - add the guard to any NEW file-mutating function, and never enforce read-only only in a route. Devii may flip read-only or visibility (private/public) only after explicit user confirmation, enforced by `dispatcher.confirmation_error` (the `CONFIRM_REQUIRED` set) before the HTTP call; mirror that pattern for any future irreversible agent action. Both owner toggle buttons (`project_detail.html`) likewise carry `data-confirm` so the human UI gates them through `app.dialog` too. **Deletions ALWAYS require confirmation:** `confirmation_error` gates EVERY content delete tool via the `CONFIRM_REQUIRED` set - `delete_post`, `delete_comment`, `delete_gist`, `delete_project`, `project_delete_file`, `delete_media`, `delete_attachment`, `admin_delete_news` - plus `container_instance_action` with `action="delete"` and any `container_exec` whose command matches `dispatcher.DESTRUCTIVE_COMMAND` (rm/rmdir/unlink/shred/truncate/dd/mkfs/wipefs, `find -delete`, redirect over a file, SQL `drop`/`delete from`). The first call is refused with a message; the agent must show the user the exact target and only then pass `confirm=true` (the `DELETING IS ALWAYS CONFIRMED` system-prompt rule mirrors this). Any new name added to `CONFIRM_REQUIRED` is auto-confirmed by a generic fallback in `confirmation_error`. **Load-bearing: every confirmation-gated tool MUST also declare a `confirm` boolean param in its catalog spec** (use the `confirm()` helper in `catalog.py`, or `arg("confirm", ..., kind="boolean")` for container actions). Tool schemas set `additionalProperties: false`, so a gated tool WITHOUT a declared `confirm` param can never receive `confirm=true` from the model - the gate refuses every call and the agent loops forever (this was a real issue on all the delete tools). The param is harmlessly forwarded to the HTTP endpoint (the routes do not declare it; FastAPI ignores undeclared form fields) and ignored by the LOCAL container/customization controllers. The delete tools stay `requires_auth` (not `requires_admin`) - members delete their own, the endpoint's owner-or-admin guard lets admins delete any.
|
||||
|
||||
## Modal pattern
|
||||
|
||||
|
||||
Reference in New Issue
Block a user