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_KEY account (username == "admin"), and
2) a database user whose role == "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_session cookie (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 routes302 redirect to /login
  • API routes401 {"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 (not 403) 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.",
        )
  • viewer can rotate own key
  • DB admin can rotate own key
  • ADMIN_KEY admin 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.py
  • tests/test_storage.py
  • tests/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 the tox and pre-commit checks from your CI system explicitly.