Project Access Grants
docsfy implements project sharing as owner-scoped ACLs. A grant is not global to a project name; it is scoped to a (project_name, project_owner) pair.
Access Model (Owner-Scoped)
Each project variant is keyed by owner, and access grants are keyed by (project_name, project_owner, username).
```56:73:src/docsfy/storage.py await db.execute(""" CREATE TABLE IF NOT EXISTS projects ( name TEXT NOT NULL, ai_provider TEXT NOT NULL DEFAULT '', ai_model TEXT NOT NULL DEFAULT '', owner TEXT NOT NULL DEFAULT '', repo_url TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'generating', current_stage TEXT, last_commit_sha TEXT, last_generated TEXT, page_count INTEGER DEFAULT 0, error_message TEXT, plan_json TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (name, ai_provider, ai_model, owner) ) """)
```237:244:src/docsfy/storage.py
await db.execute("""
CREATE TABLE IF NOT EXISTS project_access (
project_name TEXT NOT NULL,
project_owner TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL,
PRIMARY KEY (project_name, project_owner, username)
)
""")
Because project_access does not include provider/model, a grant applies to all variants of that project for that owner.
```392:405:src/docsfy/storage.py async def grant_project_access( project_name: str, username: str, project_owner: str = "" ) -> None: """Grant a user access to all variants of a project.""" if not project_owner: msg = "project_owner is required for access grants" raise ValueError(msg) async with aiosqlite.connect(DB_PATH) as db: await db.execute( "INSERT OR IGNORE INTO project_access (project_name, project_owner, username) VALUES (?, ?, ?)", (project_name, project_owner, username), ) await db.commit()
> **Note:** Project sharing is API-first. The admin HTML page in `src/docsfy/templates/admin.html` manages users, while grant/revoke flows are exercised via API calls in `test-plans/e2e-ui-test-plan.md`.
## Grant/Revoke/List APIs
All project-access APIs are admin-only.
```1203:1206:src/docsfy/main.py
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")
```1266:1310:src/docsfy/main.py @app.post("/api/admin/projects/{name}/access") async def grant_access(request: Request, name: str) -> dict[str, str]: _require_admin(request) body = await request.json() username = body.get("username", "") project_owner = body.get("owner", "") if not username: raise HTTPException(status_code=400, detail="Username is required") if not project_owner: raise HTTPException(status_code=400, detail="Project owner is required") # Validate user exists user = await get_user_by_username(username) if not user: raise HTTPException(status_code=404, detail=f"User '{username}' not found") # Validate project exists for the specified owner variants = await list_variants(name, owner=project_owner) if not variants: raise HTTPException( status_code=404, detail=f"Project '{name}' not found for owner '{project_owner}'", ) await grant_project_access(name, username, project_owner=project_owner) logger.info( f"[AUDIT] Admin '{request.state.username}' granted '{username}' access to '{name}' (owner: '{project_owner}')" ) return {"granted": name, "username": username, "owner": project_owner}
@app.delete("/api/admin/projects/{name}/access/{username}") async def revoke_access(request: Request, name: str, username: str) -> dict[str, str]: _require_admin(request) project_owner = request.query_params.get("owner", "") await revoke_project_access(name, username, project_owner=project_owner) logger.info( f"[AUDIT] Admin '{request.state.username}' revoked '{username}' access to '{name}' (owner: '{project_owner}')" ) return {"revoked": name, "username": username}
@app.get("/api/admin/projects/{name}/access") async def list_access(request: Request, name: str) -> dict[str, Any]: _require_admin(request) project_owner = request.query_params.get("owner", "") users = await get_project_access(name, project_owner=project_owner) return {"project": name, "owner": project_owner, "users": users}
Real API usage examples in the repo:
```1994:1994:test-plans/e2e-ui-test-plan.md
agent-browser javascript "fetch('/api/admin/projects/for-testing-only/access', { method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', body: JSON.stringify({username: 'testviewer-e2e', owner: 'testuser-e2e'}) }).then(r => r.json()).then(d => JSON.stringify(d))"
```2054:2054:test-plans/e2e-ui-test-plan.md agent-browser eval "fetch('/api/admin/projects/for-testing-only/access?owner=testuser-e2e', {credentials:'same-origin'}).then(r => r.json())"
```2069:2069:test-plans/e2e-ui-test-plan.md
agent-browser eval "fetch('/api/admin/projects/for-testing-only/access/testviewer-e2e?owner=testuser-e2e', {method:'DELETE', credentials:'same-origin'}).then(r => r.status)"
Warning: Always pass
ownerforGET /api/admin/projects/{name}/accessandDELETE /api/admin/projects/{name}/access/{username}. These handlers defaultownerto"", so omitting it usually targets no real owner-scoped grants.
Non-Owner Visibility Rules
For non-admin users, docsfy combines owned projects with explicitly granted (name, owner) tuples on dashboard and status APIs:
```334:345:src/docsfy/main.py @app.get("/", response_class=HTMLResponse) async def dashboard(request: Request) -> HTMLResponse: settings = get_settings() username = request.state.username is_admin = request.state.is_admin
if is_admin:
projects = await list_projects() # admin sees all
else:
accessible = await get_user_accessible_projects(username)
projects = await list_projects(owner=username, accessible=accessible)
```366:379:src/docsfy/storage.py
async def list_projects(
owner: str | None = None,
accessible: list[tuple[str, str]] | None = None,
) -> list[dict[str, str | int | None]]:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
if owner is not None and accessible and len(accessible) > 0:
# Build OR conditions for each (name, owner) pair
conditions = ["(owner = ?)"]
params: list[str] = [owner]
for proj_name, proj_owner in accessible:
conditions.append("(name = ? AND owner = ?)")
Visibility checks return 404 for unauthorized project access (to avoid existence leaks), not 403:
```194:207:src/docsfy/main.py async def _check_ownership( request: Request, project_name: str, project: dict[str, Any] ) -> None: """Raise 404 if the requesting user does not own the project (unless admin).""" if request.state.is_admin: return project_owner = str(project.get("owner", "")) if project_owner == request.state.username: return # Check if user has been granted access (scoped by project_owner) 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")
```580:608:tests/test_auth.py
async def test_non_owner_cannot_access_project(_init_db: None) -> None:
"""Non-admin user should not see projects owned by others."""
from docsfy.main import _generating, app
from docsfy.storage import create_user, save_project
_generating.clear()
_, bob_key = await create_user("bob-noowner")
await save_project(
name="secret-proj",
repo_url="https://github.com/org/secret.git",
ai_provider="claude",
ai_model="opus",
owner="alice-owner2",
)
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport,
base_url="http://test",
headers={"Authorization": f"Bearer {bob_key}"},
) as ac:
# GET /api/projects/{name} - returns 404 to avoid leaking existence
response = await ac.get("/api/projects/secret-proj")
assert response.status_code == 404
# GET /api/projects/{name}/{provider}/{model}
response = await ac.get("/api/projects/secret-proj/claude/opus")
assert response.status_code == 404
Route Behavior Matrix (Non-Admin)
- Grant-aware routes:
//api/status/status/{name}/{provider}/{model}/api/projects/{name}/{provider}/{model}/api/projects/{name}/{provider}/{model}/download/docs/{project}/{provider}/{model}/{path:path}- Owner-only (for non-admin) routes:
/api/projects/{name}/api/projects/{name}/download/docs/{project}/{path:path}
Evidence for owner-only generic routes:
```1115:1123:src/docsfy/main.py @app.get("/api/projects/{name}") async def get_project_details(request: Request, name: str) -> dict[str, Any]: name = _validate_project_name(name) if request.state.is_admin: variants = await list_variants(name) else: variants = await list_variants(name, owner=request.state.username) if not variants: raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
```1158:1165:src/docsfy/main.py
@app.get("/api/projects/{name}/download")
async def download_project(request: Request, name: str) -> StreamingResponse:
name = _validate_project_name(name)
if request.state.is_admin:
latest = await get_latest_variant(name)
else:
latest = await get_latest_variant(name, owner=request.state.username)
```1406:1418:src/docsfy/main.py @app.get("/docs/{project}/{path:path}") async def serve_docs( request: Request, project: str, path: str = "index.html" ) -> FileResponse: """Serve the most recently generated variant.""" if not path or path == "/": path = "index.html" project = _validate_project_name(project) if request.state.is_admin: latest = await get_latest_variant(project) else: latest = await get_latest_variant(project, owner=request.state.username)
> **Tip:** For shared access, use variant-specific URLs (`/docs/{project}/{provider}/{model}/...` and `/api/projects/{name}/{provider}/{model}...`) because those routes resolve owner grants via `_resolve_project`.
## Revocation and Cleanup Semantics
Revocation is enforced at route level, not just UI hiding:
```2207:2228:test-plans/e2e-ui-test-plan.md
**Try accessing docs directly:**
agent-browser eval "fetch('/docs/for-testing-only/gemini/gemini-2.5-flash/index.html', {credentials:'same-origin'}).then(r => r.status)"
**Try accessing status page directly:**
agent-browser eval "fetch('/status/for-testing-only/gemini/gemini-2.5-flash', {credentials:'same-origin'}).then(r => r.status)"
**Try accessing download API directly:**
agent-browser eval "fetch('/api/projects/for-testing-only/gemini/gemini-2.5-flash/download', {credentials:'same-origin'}).then(r => r.status)"
**Check:** All direct URL accesses return 404, not just hidden from the dashboard.
**Expected result:**
- Docs endpoint returns `404`
- Status page endpoint returns `404`
- Download API endpoint returns `404`
- Revocation is enforced at the route level, not just UI level
docsfy also performs ACL cleanup when data is deleted:
```453:480:src/docsfy/storage.py async def delete_project( name: str, ai_provider: str = "", ai_model: str = "", owner: str | None = None ) -> bool: async with aiosqlite.connect(DB_PATH) as db: query = ( "DELETE FROM projects WHERE name = ? AND ai_provider = ? AND ai_model = ?" ) params: list[str] = [name, ai_provider, ai_model] if owner is not None: query += " AND owner = ?" params.append(owner) cursor = await db.execute(query, params)
# Clean up project_access if no more variants remain for this name+owner
if cursor.rowcount > 0 and owner is not None:
remaining = await db.execute(
"SELECT COUNT(*) FROM projects WHERE name = ? AND owner = ?",
(name, owner),
)
row = await remaining.fetchone()
if row and row[0] == 0:
await db.execute(
"DELETE FROM project_access WHERE project_name = ? AND project_owner = ?",
(name, owner),
)
await db.commit()
return cursor.rowcount > 0
```646:657:src/docsfy/storage.py
async def delete_user(username: str) -> bool:
"""Delete a user by username, invalidating all their sessions and cleaning up ACLs."""
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("DELETE FROM sessions WHERE username = ?", (username,))
# Clean up owned projects and their access entries
await db.execute("DELETE FROM projects WHERE owner = ?", (username,))
await db.execute(
"DELETE FROM project_access WHERE project_owner = ?", (username,)
)
# Clean up ACL entries where user was granted access
await db.execute("DELETE FROM project_access WHERE username = ?", (username,))
```446:470:tests/test_storage.py async def test_delete_project_cleans_up_access(db_path: Path) -> None: from docsfy.storage import ( delete_project, get_project_access, grant_project_access, save_project, )
await save_project(
name="cleanup-proj",
repo_url="https://github.com/org/repo.git",
ai_provider="claude",
ai_model="opus",
owner="testuser",
)
await grant_project_access("cleanup-proj", "alice", project_owner="testuser")
# Delete the only variant
await delete_project(
"cleanup-proj", ai_provider="claude", ai_model="opus", owner="testuser"
)
# Access entries should be cleaned up
users = await get_project_access("cleanup-proj", project_owner="testuser")
assert len(users) == 0
## Viewer and Read-Only Behavior with Grants
Viewers can see assigned projects, but write operations remain blocked.
```185:191: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.",
)
```1481:1492:src/docsfy/templates/dashboard.html {% if variant.status == 'ready' %}
{% if role != 'viewer' %} {{ regen_controls(variant, repo_name, default_provider, default_model, known_models) }} {% endif %}```668:700:tests/test_auth.py
async def test_viewer_sees_assigned_projects(_init_db: None) -> None:
"""A viewer with granted access should see assigned projects."""
from docsfy.main import _generating, app
from docsfy.storage import create_user, grant_project_access, save_project
_generating.clear()
_, viewer_key = await create_user("viewer-assigned", role="viewer")
# Create a project owned by someone else
await save_project(
name="assigned-proj",
repo_url="https://github.com/org/assigned.git",
ai_provider="claude",
ai_model="opus",
owner="other-owner",
)
# Grant viewer access to the project (scoped by project owner)
await grant_project_access(
"assigned-proj", "viewer-assigned", project_owner="other-owner"
)
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport,
base_url="http://test",
headers={"Authorization": f"Bearer {viewer_key}"},
) as ac:
response = await ac.get("/api/status")
assert response.status_code == 200
projects = response.json()["projects"]
project_names = [p["name"] for p in projects]
assert "assigned-proj" in project_names
Required Configuration
ADMIN_KEY is mandatory and must be at least 16 characters.
```80:89:src/docsfy/main.py @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: settings = get_settings() 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)
```16:22:src/docsfy/config.py
admin_key: str = "" # Required — validated at startup
ai_provider: str = "claude"
ai_model: str = "claude-opus-4-6[1m]" # [1m] = 1 million token context window
ai_cli_timeout: int = Field(default=60, gt=0)
log_level: str = "INFO"
data_dir: str = "/data"
secure_cookies: bool = True # Set to False for local HTTP dev
```1:8:.env.example
REQUIRED - Admin key for user management (minimum 16 characters)
ADMIN_KEY=your-secure-admin-key-here-min-16-chars
AI Configuration
AI_PROVIDER=claude
[1m] = 1 million token context window, this is a valid model identifier
AI_MODEL=claude-opus-4-6[1m] AI_CLI_TIMEOUT=60
```6:8:docker-compose.yaml
env_file: .env
volumes:
- ./data:/data
Warning: Keep
SECURE_COOKIES=trueoutside local HTTP development; admin APIs and grants are protected by authenticated sessions/bearer auth.
Validation Coverage
Automated tests are configured through tox:
```1:7:tox.toml skipsdist = true
envlist = ["unittests"]
[env.unittests] deps = ["uv"] commands = [["uv", "run", "--extra", "dev", "pytest", "-n", "auto", "tests"]] ```