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 owner for GET /api/admin/projects/{name}/access and DELETE /api/admin/projects/{name}/access/{username}. These handlers default owner to "", 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' %}

View Docs Download {% if role != 'viewer' %} {% endif %}
{% 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=true outside 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"]] ```