Roles and Permissions
docsfy uses role-based access control (RBAC) across both UI and API layers. Roles are stored in src/docsfy/storage.py and enforced in src/docsfy/main.py.
Role Definitions
| Role | Intended use | Write-protected APIs | Admin APIs |
|---|---|---|---|
admin |
Full platform control | Allowed | Allowed |
user |
Normal project owner/contributor | Allowed | Denied |
viewer |
Read-only docs/project access | Denied | Denied |
VALID_ROLES = frozenset({"admin", "user", "viewer"})
Note: There are two admin paths in implementation: 1) the environment
ADMIN_KEYaccount (username == "admin"), and
2) a database user whoserole == "admin".
# Determine the role
if is_admin:
role = "admin"
if not username:
username = "admin"
else:
assert user is not None # guaranteed by the guard above
role = str(user.get("role", "user"))
username = str(user["username"])
# Fix 6: DB user with admin role gets admin privileges
if role == "admin":
is_admin = True
Authentication and Request Enforcement
Authentication accepts:
Authorization: Bearer <token>(API clients)docsfy_sessioncookie (browser sessions)
Public routes are only /login and /health.
# Paths that do not require authentication
_PUBLIC_PATHS = frozenset({"/login", "/login/", "/health"})
...
if not user and not is_admin:
# Not authenticated
if request.url.path.startswith("/api/"):
return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
return RedirectResponse(url="/login", status_code=302)
So unauthenticated behavior is:
- UI routes →
302redirect to/login - API routes →
401 {"detail":"Unauthorized"}
UI Capability Matrix
| UI action | admin | user | viewer | How it is enforced |
|---|---|---|---|---|
Open dashboard (/) |
✅ | ✅ | ✅ | Auth middleware |
See Admin link in header |
✅ | ❌ | ❌ | dashboard.html conditional |
Open admin panel (/admin) |
✅ | ❌ | ❌ | _require_admin() |
| See Generate form | ✅ | ✅ | ❌ | dashboard.html conditional |
| Generate/regenerate/abort/delete controls | ✅ | ✅ | ❌ | UI conditional + API guard |
| View docs / download accessible variants | ✅ | ✅ | ✅ | ownership/grant resolution |
| Change own password button | ✅* | ✅ | ✅ | visible for all authenticated users |
* ADMIN_KEY admin cannot rotate via /api/me/rotate-key (details below).
{% if role == 'admin' %}
<a href="/admin" class="top-bar-admin-link">Admin</a>
{% endif %}
{% if role != 'viewer' %}
<section class="generate-section">
...
</section>
{% endif %}
{% if role != 'viewer' %}
<button class="btn btn-danger btn-sm" data-delete-variant="...">Delete</button>
{% endif %}
Write-Protected API Permissions
All non-admin/non-user write attempts are rejected by a shared guard:
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.",
)
General write APIs (admin + user only)
| Endpoint | admin | user | viewer |
|---|---|---|---|
POST /api/generate |
✅ | ✅ | ❌ (403) |
POST /api/projects/{name}/abort |
✅ | ✅ | ❌ (403) |
POST /api/projects/{name}/{provider}/{model}/abort |
✅ | ✅ | ❌ (403) |
DELETE /api/projects/{name}/{provider}/{model} |
✅ | ✅ | ❌ (403) |
DELETE /api/projects/{name} |
✅ | ✅ | ❌ (403) |
Additional restriction on generation source:
# Fix 9: Local repo path access requires admin privileges
if gen_request.repo_path and not request.state.is_admin:
raise HTTPException(
status_code=403,
detail="Local repo path access requires admin privileges",
)
Admin-only APIs (admin only)
| Endpoint | admin | user | viewer |
|---|---|---|---|
GET /admin |
✅ | ❌ (403) |
❌ (403) |
POST /api/admin/users |
✅ | ❌ (403) |
❌ (403) |
GET /api/admin/users |
✅ | ❌ (403) |
❌ (403) |
DELETE /api/admin/users/{username} |
✅ | ❌ (403) |
❌ (403) |
POST /api/admin/users/{username}/rotate-key |
✅ | ❌ (403) |
❌ (403) |
POST /api/admin/projects/{name}/access |
✅ | ❌ (403) |
❌ (403) |
GET /api/admin/projects/{name}/access |
✅ | ❌ (403) |
❌ (403) |
DELETE /api/admin/projects/{name}/access/{username} |
✅ | ❌ (403) |
❌ (403) |
def _require_admin(request: Request) -> None:
"""Raise 403 if the user is not an admin."""
if not request.state.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
Ownership, Sharing, and Visibility Rules
docsfy enforces ownership boundaries plus explicit grants:
- owners can access their own variants
- admins can access all
- non-owners can access only if granted in
project_access
async def _check_ownership(request: Request, project_name: str, project: dict[str, Any]) -> None:
if request.state.is_admin:
return
project_owner = str(project.get("owner", ""))
if project_owner == request.state.username:
return
access = await get_project_access(project_name, project_owner=project_owner)
if request.state.username in access:
return
raise HTTPException(status_code=404, detail="Not found")
if owner is not None and accessible and len(accessible) > 0:
# Build OR conditions for each (name, owner) pair
conditions = ["(owner = ?)"]
...
Warning: Unauthorized project access intentionally returns
404(not403) to avoid leaking resource existence.
Shared-access route behavior
- Grant-aware routes use
_resolve_project(): /api/projects/{name}/{provider}/{model}/api/projects/{name}/{provider}/{model}/download/docs/{project}/{provider}/{model}/{path}- Owner-scoped (non-admin) routes filter by
owner=request.state.username: /api/projects/{name}/api/projects/{name}/download/docs/{project}/{path}
Tip: For users who received access via admin grant, prefer variant-scoped routes (
/{provider}/{model}) for reliable access to shared projects.
Password / API Key Rotation Semantics
Users (including viewer) can rotate their own key. This endpoint explicitly bypasses write-role restrictions.
@app.post("/api/me/rotate-key")
async def rotate_own_key(request: Request) -> JSONResponse:
"""User rotates their own API key."""
# Don't call _require_write_access -- viewers should be able to change their password
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.",
)
viewercan rotate own key- DB
admincan rotate own key ADMIN_KEYadmin cannot rotate through API; rotate the env var instead- admin can rotate any user key via
/api/admin/users/{username}/rotate-key
Security and Configuration Snippets
ADMIN_KEY is mandatory and is also used for HMAC key hashing.
# REQUIRED - Admin key for user management (minimum 16 characters)
ADMIN_KEY=your-secure-admin-key-here-min-16-chars
# Set to false for local HTTP development
# SECURE_COOKIES=false
admin_key: str = "" # Required — validated at startup
secure_cookies: bool = True # Set to False for local HTTP dev
Session cookie settings at login:
response.set_cookie(
"docsfy_session",
session_token,
httponly=True,
samesite="strict",
secure=settings.secure_cookies,
max_age=SESSION_TTL_SECONDS,
)
Session tokens are opaque and stored hashed:
token = secrets.token_urlsafe(32)
token_hash = _hash_session_token(token)
...
"INSERT INTO sessions (token, username, is_admin, expires_at) VALUES (?, ?, ?, ?)"
Verification Coverage (Tests and Pipeline Config)
Role and permission behavior is covered in tests such as:
tests/test_auth.pytests/test_storage.pytests/test_main.py
Example assertions:
# Viewer is blocked from write API
response = await ac.post("/api/generate", json={"repo_url": "https://github.com/org/repo"})
assert response.status_code == 403
assert "Write access required" in response.json()["detail"]
# Non-owner gets 404 (no resource existence leak)
response = await ac.get("/api/projects/secret-proj")
assert response.status_code == 404
Automated test command configured in tox.toml:
[env.unittests]
deps = ["uv"]
commands = [["uv", "run", "--extra", "dev", "pytest", "-n", "auto", "tests"]]
Warning: No repository workflow files were found under
.github/workflows; if you enforce permissions checks in CI/CD, run thetoxand pre-commit checks from your CI system explicitly.