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:
- Environment admin: username
admin+ADMIN_KEY. - 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=falseso browser sessions work overhttp://.
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
- Log in as an admin.
- Open
/admin. - Enter username and select role (
user,admin,viewer). - Submit Create User.
- 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
- Open
/admin. - Click Delete on the target user row.
- Confirm in modal dialog.
- 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:
viewerusers 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_KEYinvalidates 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"]]