User Management

docsfy uses API-key-based authentication with session cookies for browser workflows. User lifecycle operations (create, rotate password, delete) are admin-controlled.

Authentication model

There are two admin paths:

  1. Environment admin: username admin + ADMIN_KEY.
  2. Database admin user: any username with role admin.
# src/docsfy/main.py
# Check admin -- username must be "admin" and key must match
if username == "admin" and api_key == settings.admin_key:
    is_admin = True
    authenticated = True
else:
    # Check user key -- verify username matches the key's owner
    user = await get_user_by_key(api_key)
    if user and user["username"] == username:
        authenticated = True
        is_admin = user.get("role") == "admin"

Note: In the UI, the login label says Password, but backend form/API field names use api_key.

Required configuration

ADMIN_KEY is mandatory and must be at least 16 characters:

# src/docsfy/main.py
if not settings.admin_key:
    logger.error("ADMIN_KEY environment variable is required")
    raise SystemExit(1)

if len(settings.admin_key) < 16:
    logger.error("ADMIN_KEY must be at least 16 characters long")
    raise SystemExit(1)
# .env.example
ADMIN_KEY=your-secure-admin-key-here-min-16-chars

# Set to false for local HTTP development
# SECURE_COOKIES=false

Session cookies are HttpOnly, SameSite=strict, and secure by default:

# src/docsfy/main.py
response.set_cookie(
    "docsfy_session",
    session_token,
    httponly=True,
    samesite="strict",
    secure=settings.secure_cookies,
    max_age=SESSION_TTL_SECONDS,
)

Tip: For local non-HTTPS development, set SECURE_COOKIES=false so browser sessions work over http://.

Roles and permissions

Roles are defined in storage:

# src/docsfy/storage.py
VALID_ROLES = frozenset({"admin", "user", "viewer"})

Write operations are blocked for viewer:

# src/docsfy/main.py
def _require_write_access(request: Request) -> None:
    """Raise 403 if user is a viewer (read-only)."""
    if request.state.role not in ("admin", "user"):
        raise HTTPException(
            status_code=403,
            detail="Write access required.",
        )

Dashboard UI also hides write controls for viewers and shows the Admin link only for admins.

Creating users

Only admins can create users (/admin UI or POST /api/admin/users).

Admin panel workflow

  1. Log in as an admin.
  2. Open /admin.
  3. Enter username and select role (user, admin, viewer).
  4. Submit Create User.
  5. Save the returned password immediately.
<!-- src/docsfy/templates/admin.html -->
<form id="create-user-form" onsubmit="createUser(event)">
    <input class="form-input" type="text" id="new-username" placeholder="Enter username" required>
    <select class="form-select" id="new-role">
        <option value="user">user</option>
        <option value="admin">admin</option>
        <option value="viewer">viewer</option>
    </select>
</form>
// src/docsfy/templates/admin.html
const resp = await fetch("/api/admin/users", {
    method: "POST",
    headers: {"Content-Type": "application/json"},
    credentials: "same-origin",
    body: JSON.stringify({username: username, role: role})
});
const data = await resp.json();
document.getElementById("new-key-value").textContent = data.api_key;
# src/docsfy/main.py
@app.post("/api/admin/users")
async def create_user_endpoint(request: Request) -> JSONResponse:
    _require_admin(request)
    body = await request.json()
    username = body.get("username", "")
    role = body.get("role", "user")
    username, raw_key = await create_user(username, role)
    return JSONResponse(
        content={"username": username, "api_key": raw_key, "role": role},
        headers={"Cache-Control": "no-store"},
    )

Warning: Generated passwords are returned once (api_key/new_api_key) and are not retrievable later.

Reserved usernames

admin is reserved (case-insensitive) for the environment-admin login convention.

# src/docsfy/storage.py
if username.lower() == "admin":
    msg = "Username 'admin' is reserved"
    raise ValueError(msg)

Validation also enforces: - length: 2-50 chars - first char: alphanumeric - allowed chars after first: alphanumeric, ., _, -

# src/docsfy/storage.py
if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{1,49}$", username):
    msg = f"Invalid username: '{username}'. Must be 2-50 alphanumeric characters, dots, hyphens, underscores."
    raise ValueError(msg)

Test coverage confirms case-insensitive reservation:

# tests/test_auth.py
response = await admin_client.post(
    "/api/admin/users",
    json={"username": "Admin", "role": "user"},
)
assert response.status_code == 400
assert "reserved" in response.json()["detail"]

Warning: Do not assign admin (any case) to regular users; creation is intentionally blocked.

Deleting users

User deletion is admin-only and irreversible from the UI flow.

Admin panel workflow

  1. Open /admin.
  2. Click Delete on the target user row.
  3. Confirm in modal dialog.
  4. User row is removed after successful API response.
// src/docsfy/templates/admin.html
const resp = await fetch("/api/admin/users/" + encodeURIComponent(username), {
    method: "DELETE",
    credentials: "same-origin",
});

Backend self-delete guard:

# src/docsfy/main.py
if username == request.state.username:
    raise HTTPException(status_code=400, detail="Cannot delete your own account")

Delete behavior in storage:

# src/docsfy/storage.py
await db.execute("DELETE FROM sessions WHERE username = ?", (username,))
await db.execute("DELETE FROM projects WHERE owner = ?", (username,))
await db.execute("DELETE FROM project_access WHERE project_owner = ?", (username,))
await db.execute("DELETE FROM project_access WHERE username = ?", (username,))
cursor = await db.execute("DELETE FROM users WHERE username = ?", (username,))

When a deleted user still has an old session cookie, requests are rejected/redirected:

# src/docsfy/main.py
if username != "admin":
    user = await get_user_by_username(username)
    if not user:
        if request.url.path.startswith("/api/"):
            return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
        return RedirectResponse(url="/login", status_code=302)

Warning: Deleting a user also deletes that user’s active sessions, owned project records, and ACL entries.

Password rotation workflows

Admin rotates another user’s password

POST /api/admin/users/{username}/rotate-key
Optional JSON body: {"new_key": "..."} (must be at least 16 chars). Empty body auto-generates a new password.

User rotates own password

POST /api/me/rotate-key
Also supports optional new_key, invalidates existing sessions, and clears the current session cookie.

# src/docsfy/main.py
if request.state.is_admin and not request.state.user:
    raise HTTPException(
        status_code=400,
        detail="ADMIN_KEY users cannot rotate keys. Change the ADMIN_KEY env var instead.",
    )

Note: viewer users are read-only for project writes, but they are still allowed to rotate their own password.

Security storage notes

User API keys are not stored raw; hashes use HMAC with ADMIN_KEY as the secret:

# src/docsfy/storage.py
# NOTE: ADMIN_KEY is used as the HMAC secret. Rotating ADMIN_KEY will
# invalidate all existing api_key_hash values, requiring all users to
# regenerate their API keys.
return hmac.new(secret.encode(), key.encode(), hashlib.sha256).hexdigest()

Warning: Rotating ADMIN_KEY invalidates all existing stored user API-key hashes.

User management API quick reference

Endpoint Method Access Purpose
/admin GET admin Admin panel UI
/api/admin/users GET admin List users
/api/admin/users POST admin Create user and return one-time api_key
/api/admin/users/{username} DELETE admin Delete user
/api/admin/users/{username}/rotate-key POST admin Rotate a user password
/api/me/rotate-key POST authenticated Rotate own password
/login GET/POST public Login page and credential submit
/logout GET authenticated End session

Verification coverage

User management behavior is covered by tests in tests/test_auth.py and tests/test_storage.py (reserved usernames, self-delete guard, cookie/session behavior, role behavior, password rotation, ACL cleanup).

Test automation entrypoint:

# tox.toml
commands = [["uv", "run", "--extra", "dev", "pytest", "-n", "auto", "tests"]]