feat: give each Kanban operator their own account
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 1m17s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Successful in 1m37s

One OPERATOR_EMAIL and OPERATOR_PASSWORD served the whole factory, so every card
movement recorded the same name and the movement history could not answer who
did what. Traceability was one of the things the project set out to provide.

Accounts live in dtf_local.operators, authenticated with the same scrypt hashing
as customer accounts and with comparable work whether or not the account exists,
so absence is not observable by timing. Administration is a CLI in the API
container, like the schema migration: list, add, password, disable, enable.
Passwords are read from the terminal rather than an argument so they stay out of
shell history and the process list, and disabling deletes that operator's open
sessions instead of leaving them valid for the rest of the eight-hour window.

Migration is the part that could hurt: an empty table means 503 and a factory
locked out of its Kanban. OPERATOR_EMAIL and OPERATOR_PASSWORD seed the first
account, and only when that email is absent, so a password changed through the
CLI survives a redeploy carrying a stale environment variable. The first attempt
at this silently did nothing, because db-init receives its own small environment
and had neither variable; both compose files now pass them to it.

Verified against a running stack: bootstrap seeds the existing credential, that
credential still logs in unchanged, a second operator authenticates separately,
wrong passwords and unknown accounts are rejected alike, and disabling revokes
an open session immediately.

Roles are left out on purpose. The separation of duties the meeting described
governs rework authorisation, which this system does not implement, so a role
model would have no consumer to serve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-21 14:13:47 -03:00
parent da903db32a
commit a87403338d
8 changed files with 215 additions and 13 deletions

View File

@@ -18,7 +18,7 @@ from .secrets import load as load_secret_files
from .adapters import FakeFreight, FakePayment, LocalS3Storage, require_runtime
from .models import Freight, Move, Pay, QuoteRequest, Review, UploadStart, OperatorLogin
from .pricing import price
from .auth import COOKIE_SECURE, client_ip, owner, session_row, new_session, operator, throttle, audit, rate_limit
from .auth import COOKIE_SECURE, DUMMY_PASSWORD_HASH, client_ip, owner, session_row, new_session, operator, password_matches, throttle, audit, rate_limit
from .scanning import require_clean
load_secret_files()
@@ -54,15 +54,17 @@ app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS)
@app.post('/api/operator/login')
def operator_login(body: OperatorLogin, request: Request, response: Response):
configured_email = os.environ.get('OPERATOR_EMAIL', '').strip().lower()
if not configured_email:
raise HTTPException(503, 'Kanban operator email is not configured')
email = body.email
throttle('operator:'+email, request)
valid_user = secrets.compare_digest(email.encode(), configured_email.encode())
valid_password = secrets.compare_digest(body.password.encode(), os.environ['OPERATOR_PASSWORD'].encode())
if not (valid_user and valid_password):
audit('operator_login_failed', ip=client_ip(request))
with db.connect() as c:
account = c.execute('SELECT * FROM dtf_local.operators WHERE email=%s', (email,)).fetchone()
if not c.execute('SELECT 1 FROM dtf_local.operators WHERE active LIMIT 1').fetchone():
raise HTTPException(503, 'No Kanban operator account is configured')
# Comparable password work whether or not the account exists or is active.
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
matches = password_matches(body.password, stored)
if not account or not account['active'] or not matches:
audit('operator_login_failed', ip=client_ip(request), operator=email)
raise HTTPException(401, 'Invalid operator login')
token = secrets.token_urlsafe(32)
with db.connect() as c:
@@ -70,6 +72,7 @@ def operator_login(body: OperatorLogin, request: Request, response: Response):
c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (previous,))
c.execute('INSERT INTO dtf_local.operator_sessions(token_hash,username) VALUES(%s,%s)',
(hashlib.sha256(token.encode()).hexdigest(), email))
c.execute('UPDATE dtf_local.operators SET last_login_at=now() WHERE id=%s', (account['id'],))
response.set_cookie('dtf_operator', token, httponly=True, secure=COOKIE_SECURE,
samesite='strict', path='/api/operator', max_age=28800)
audit('operator_login_success', operator=email)