T10.9 D14 - the CLI authenticates against the domain; create-admin/create removed
Accounts are not created here any more. D13 provisions them on first successful
sign-in, so create-admin and create were redundant - and worse than redundant,
because a hand-typed username can end up matching no directory identity at all.
Removing them means every row now originates from a bind, which closes that
class of problem for everything except the rows the old CLI already made.
promote and demote replace them. Bootstrapping the first admin is now two steps
in order: sign in once, which provisions the account at project_user, then
promote your own sAMAccountName.
Every state-changing command requires a prompted domain bind. No --password
flag on anything, deliberately: that would put a live domain password into shell
history and into ps output for every other user on the box. `list` needs no
credential so an outage stays diagnosable.
Two deliberate divergences from the API, both commented at the code:
- The bind does NOT apply the login group gate. If a mistyped required group
locks everyone out of the console, this tool must still work, or the only
route to fixing the lockout is the thing the lockout prevents.
- Changing your OWN role is permitted. set_user_role in app.py forbids it to
stop an admin locking themselves out of the console; here it is the entire
bootstrap path. Allowed, and recorded with {"self": true}.
Kept from set_user_role: the last-admin guard, and clearing auto_add_projects
on promotion to admin (an admin already reaches every project, so the flag
would sit there invisible and spring back on demotion).
What this is worth, said plainly in the module docstring rather than implied:
anyone with a shell here can still write to the users table with psql or
sqlite3, so the bind is defence in depth and mostly ACCOUNTABILITY. Before
this, every role change from a shell was invisible in AuditLog while the same
change through the console was recorded. Now both are recorded and both name a
person. Any-domain-user was accepted as sufficient knowing that.
Verified: the three removed commands are rejected as invalid choices; list runs
with no credential; a state-changing command with LDAP misconfigured refuses
rather than proceeding unauthenticated; promote, demote, self-promotion, the
last-admin guard, the unknown-account message, and one audit row per change all
behave, with the bind stubbed.
Left open rather than ticked: none of this has been run against a real bind.
authenticate_operator was stubbed for the logic tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -155,3 +155,56 @@ Two consequences worth knowing:
|
||||
- Group-to-role mapping (e.g. an AD group that confers `project_admin`). Criterion 4 keeps
|
||||
authorization local on purpose. Worth its own item later; logged in `backlog.md`.
|
||||
- Replacing the Let's Encrypt certificate or changing anything on the OpenResty host.
|
||||
|
||||
---
|
||||
|
||||
## D14 — The CLI authenticates against the domain, and stops creating accounts
|
||||
|
||||
- **Amends:** `D13` criterion 4, which said role granting keeps working *from the Admin
|
||||
console*. It said nothing about `manage_users.py`, which had no authentication of any
|
||||
kind. Requiring one is a new requirement, so it gets its own id rather than widening
|
||||
criterion 4.
|
||||
- **Surface:** `server/manage_users.py`
|
||||
- **Task:** `T10.9`
|
||||
|
||||
### The decision
|
||||
|
||||
1. **`create-admin` and `create` are removed.** `D13` provisions accounts on first
|
||||
successful sign-in, so creating them by hand is redundant. Removing them also closes a
|
||||
class of problem: every row now originates from a bind, so a username cannot be typed
|
||||
in wrong and end up orphaned from the directory identity it was meant to match. That
|
||||
risk now applies only to rows the old CLI already created.
|
||||
2. **`promote` and `demote` replace them.** The directory supplies identity; this app
|
||||
supplies authorization, and this is where authorization is assigned from a shell.
|
||||
3. **Every state-changing command requires a domain bind.** Prompted, via `getpass`.
|
||||
There is deliberately no `--password` flag: that would put a live domain password into
|
||||
shell history and into `ps` output for every other user on the box.
|
||||
4. **`list` needs no credential**, so an outage stays diagnosable.
|
||||
|
||||
### Bootstrapping the first admin, which changed shape
|
||||
|
||||
Two steps, in order: **sign in once** (which provisions the account at `project_user`),
|
||||
then **`promote <sAMAccountName>`**. Before D14 the first admin was created with a
|
||||
password; there is no password now, and no account to create.
|
||||
|
||||
### What this is worth, stated plainly
|
||||
|
||||
Anyone with a shell on the api container can still write to the `users` table directly
|
||||
with `psql` or `sqlite3`. So the bind is **defence in depth and, mostly,
|
||||
ACCOUNTABILITY** — not a security boundary. Before D14 every role change made from a
|
||||
shell was invisible in `AuditLog` while the same change through the console was recorded;
|
||||
now both are recorded and both name a person. Any-domain-user was accepted as sufficient
|
||||
(no privileged group exists on this estate, and machine access is already restricted to a
|
||||
few people), which was decided knowing the above.
|
||||
|
||||
### Two deliberate divergences from the API
|
||||
|
||||
- **The bind does NOT apply the login group gate.** If a mistyped required group locks
|
||||
everyone out of the console, this tool has to still work — otherwise the only route to
|
||||
fixing the lockout is the thing the lockout prevents.
|
||||
- **Changing your OWN role is permitted here.** `set_user_role` in `app.py` forbids it to
|
||||
stop an admin locking themselves out of the console. Here it is the entire bootstrap
|
||||
path, so it is allowed and recorded with `{"self": true}` in the audit detail.
|
||||
|
||||
The last-admin guard is kept, matching `set_user_role`: an app with no admin cannot be
|
||||
administered, and there is no password login left to recover through.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Wave 10 — Domain authentication over LDAPS
|
||||
|
||||
**Items:** `D13`
|
||||
**Items:** `D13`, `D14`
|
||||
**Depends on:** wave 9 merged (it is — `a8e28bf`)
|
||||
|
||||
One item, eight tasks. The item is stated in `docs/waves/decisions-2026-08-21.md`; read it
|
||||
@@ -337,3 +337,36 @@ happens when the DC is unreachable, whatever `T10.2` decides.
|
||||
- [ ] the DC-unreachable behaviour is stated explicitly
|
||||
- [ ] `IMPLEMENTATION.md` section 4 lists wave 10
|
||||
- [ ] no doc still claims passwords are stored as bcrypt hashes
|
||||
|
||||
---
|
||||
|
||||
### T10.9 — D14: the CLI authenticates, and stops creating accounts
|
||||
|
||||
- **Items:** `D14`
|
||||
- **Depends on:** T10.3
|
||||
- **Blocks:** T10.8
|
||||
- **Surface:** `server/`
|
||||
- **Files:** `server/manage_users.py`
|
||||
|
||||
**Problem:** `manage_users.py` writes to the `users` table with no authentication at all.
|
||||
It also still offers `create-admin` / `create`, which are redundant now that accounts
|
||||
provision themselves — and worse than redundant, because a hand-typed username can end up
|
||||
matching no directory identity.
|
||||
|
||||
**Do:** As stated in `D14`. Remove the two create commands, add `promote` / `demote`, gate
|
||||
every state-changing command on a prompted domain bind, and write an `AuditLog` row naming
|
||||
the operator. Write the audit row by hand rather than importing `log_event` from `app.py` —
|
||||
that would pull FastAPI and the whole application into a CLI startup for one INSERT.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [x] `create-admin`, `create` and `reset-password` are rejected as invalid choices
|
||||
- [x] `list` works with no credential and with no LDAP configured
|
||||
- [x] a state-changing command with LDAP misconfigured refuses instead of proceeding
|
||||
- [x] there is no `--password` flag on any command
|
||||
- [x] `promote` raises a role; `demote` returns an account to `project_user`
|
||||
- [x] promoting YOURSELF is allowed and recorded with `self: true`
|
||||
- [x] the last active admin cannot be demoted
|
||||
- [x] an unknown account gives an error that says accounts are made on first sign-in
|
||||
- [x] every change writes an `AuditLog` row naming the operator
|
||||
- [ ] verified against a real domain bind rather than a stubbed `authenticate_operator`
|
||||
|
||||
Reference in New Issue
Block a user