vehlo-code-scanner 0.1.1rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. vcs/__init__.py +1 -0
  2. vcs/api/__init__.py +1 -0
  3. vcs/api/app.py +92 -0
  4. vcs/api/auth.py +95 -0
  5. vcs/api/deps.py +161 -0
  6. vcs/api/routes/__init__.py +1 -0
  7. vcs/api/routes/analytics.py +79 -0
  8. vcs/api/routes/auth.py +169 -0
  9. vcs/api/routes/findings.py +141 -0
  10. vcs/api/routes/groups.py +122 -0
  11. vcs/api/routes/health.py +11 -0
  12. vcs/api/routes/ingest.py +49 -0
  13. vcs/api/routes/overview.py +95 -0
  14. vcs/api/routes/repos.py +106 -0
  15. vcs/api/routes/scans.py +79 -0
  16. vcs/api/routes/tokens.py +178 -0
  17. vcs/api/schemas.py +172 -0
  18. vcs/api/services/__init__.py +1 -0
  19. vcs/api/services/auto_resolve.py +55 -0
  20. vcs/api/services/ingest.py +205 -0
  21. vcs/api/session.py +32 -0
  22. vcs/cli/__init__.py +1 -0
  23. vcs/cli/admin.py +177 -0
  24. vcs/cli/app.py +19 -0
  25. vcs/cli/output.py +68 -0
  26. vcs/cli/scan_command.py +195 -0
  27. vcs/client/__init__.py +1 -0
  28. vcs/client/api.py +108 -0
  29. vcs/config.py +150 -0
  30. vcs/db.py +47 -0
  31. vcs/enums.py +55 -0
  32. vcs/fingerprint.py +17 -0
  33. vcs/models/__init__.py +22 -0
  34. vcs/models/api_token.py +39 -0
  35. vcs/models/base.py +32 -0
  36. vcs/models/finding.py +81 -0
  37. vcs/models/finding_history.py +42 -0
  38. vcs/models/group.py +35 -0
  39. vcs/models/organization.py +23 -0
  40. vcs/models/repo.py +56 -0
  41. vcs/models/scan.py +62 -0
  42. vcs/models/user.py +53 -0
  43. vcs/scanner/__init__.py +1 -0
  44. vcs/scanner/models.py +67 -0
  45. vcs/scanner/parser.py +25 -0
  46. vcs/scanner/runner.py +146 -0
  47. vcs/services/__init__.py +1 -0
  48. vcs/services/provisioning.py +144 -0
  49. vcs/services/tokens.py +74 -0
  50. vcs/storage/__init__.py +55 -0
  51. vcs/storage/memory.py +19 -0
  52. vcs/storage/s3.py +49 -0
  53. vcs/worker/__init__.py +1 -0
  54. vcs/worker/celery_app.py +17 -0
  55. vcs/worker/db.py +33 -0
  56. vcs/worker/tasks.py +48 -0
  57. vehlo_code_scanner-0.1.1rc1.dist-info/METADATA +198 -0
  58. vehlo_code_scanner-0.1.1rc1.dist-info/RECORD +60 -0
  59. vehlo_code_scanner-0.1.1rc1.dist-info/WHEEL +4 -0
  60. vehlo_code_scanner-0.1.1rc1.dist-info/entry_points.txt +3 -0
vcs/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Vehlo Code Scanner — multi-tenant security scanning platform."""
vcs/api/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """API module — FastAPI application and routes."""
vcs/api/app.py ADDED
@@ -0,0 +1,92 @@
1
+ """FastAPI application factory."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from fastapi import FastAPI
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from fastapi.responses import FileResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+ from starlette.middleware.sessions import SessionMiddleware
11
+
12
+ from vcs.api.routes import (
13
+ analytics,
14
+ auth,
15
+ findings,
16
+ groups,
17
+ health,
18
+ ingest,
19
+ overview,
20
+ repos,
21
+ scans,
22
+ tokens,
23
+ )
24
+ from vcs.config import get_cors_origins, get_session_secret
25
+
26
+
27
+ def create_app() -> FastAPI:
28
+ """Create and configure the FastAPI application."""
29
+ app = FastAPI(
30
+ title="Vehlo Code Scanner API",
31
+ description="Central API for security scan result ingestion and findings management.",
32
+ version="0.1.0",
33
+ )
34
+ # SessionMiddleware holds transient OIDC state (CSRF/nonce) during the
35
+ # login handshake. The authenticated session is a separate signed cookie.
36
+ app.add_middleware(SessionMiddleware, secret_key=get_session_secret())
37
+ # CORS: explicit origins from VCS_CORS_ORIGINS when set (empty = deny all
38
+ # cross-origin — right for prod, where the SPA is same-origin). The
39
+ # any-localhost-port regex is a dev-only default; with credentials
40
+ # allowed it must not reach a real deployment.
41
+ origins = get_cors_origins()
42
+ cors_scope = (
43
+ {"allow_origins": origins}
44
+ if origins is not None
45
+ else {"allow_origin_regex": r"http://localhost:\d+"}
46
+ )
47
+ app.add_middleware(
48
+ CORSMiddleware,
49
+ allow_credentials=True,
50
+ allow_methods=["*"],
51
+ allow_headers=["*"],
52
+ **cors_scope,
53
+ )
54
+ app.include_router(health.router)
55
+ app.include_router(auth.router)
56
+ app.include_router(tokens.router)
57
+ app.include_router(groups.router)
58
+ app.include_router(ingest.router)
59
+ app.include_router(findings.router)
60
+ app.include_router(overview.router)
61
+ app.include_router(repos.router)
62
+ app.include_router(scans.router)
63
+ app.include_router(analytics.router)
64
+ _mount_dashboard(app)
65
+ return app
66
+
67
+
68
+ def _mount_dashboard(app: FastAPI) -> None:
69
+ """Serve the built dashboard SPA from the API when bundled.
70
+
71
+ Active only when VCS_DASHBOARD_DIR points at a build (so tests/local
72
+ are unaffected). API routes are registered first and take precedence;
73
+ the catch-all serves real files, else index.html for client-side routes.
74
+ """
75
+ dist = os.environ.get("VCS_DASHBOARD_DIR")
76
+ if not dist:
77
+ return
78
+ root = Path(dist)
79
+ index = root / "index.html"
80
+ if not index.is_file():
81
+ return
82
+ if (root / "assets").is_dir():
83
+ app.mount(
84
+ "/assets", StaticFiles(directory=root / "assets"), name="assets"
85
+ )
86
+
87
+ @app.get("/{full_path:path}", include_in_schema=False)
88
+ def spa(full_path: str) -> FileResponse:
89
+ candidate = root / full_path
90
+ if full_path and candidate.is_file():
91
+ return FileResponse(candidate)
92
+ return FileResponse(index)
vcs/api/auth.py ADDED
@@ -0,0 +1,95 @@
1
+ """Authentication principal and the global tenant-isolation safety net.
2
+
3
+ A single ``AuthPrincipal`` represents whoever is making a request — a machine
4
+ token (exactly one group) or, later, a human SSO session (their group
5
+ memberships). Every isolation rule is expressed once against
6
+ ``accessible_group_ids`` so both auth modes share the same enforcement.
7
+
8
+ The isolation itself is a SQLAlchemy ``do_orm_execute`` listener: any session
9
+ whose ``info`` carries ``accessible_group_ids`` automatically has a
10
+ group-scoped predicate injected into every SELECT of ``Repo``/``Finding``/
11
+ ``Scan``. This is a safety net — a forgotten ``.filter()`` in a route cannot
12
+ leak another group's data, because the predicate is applied at the session
13
+ layer, not by the route.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import uuid
19
+ from dataclasses import dataclass
20
+
21
+ from sqlalchemy import event, select
22
+ from sqlalchemy.orm import Session, with_loader_criteria
23
+
24
+ SCOPE_KEY = "accessible_group_ids"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class AuthPrincipal:
29
+ """The authenticated caller and the groups they may access."""
30
+
31
+ kind: str # "token" | "user"
32
+ org_id: uuid.UUID
33
+ group_ids: frozenset[uuid.UUID]
34
+ token_id: uuid.UUID | None = None
35
+ user_id: uuid.UUID | None = None
36
+
37
+ @property
38
+ def accessible_group_ids(self) -> frozenset[uuid.UUID]:
39
+ return self.group_ids
40
+
41
+ @property
42
+ def primary_group_id(self) -> uuid.UUID:
43
+ """The single group a write (ingest) is attributed to.
44
+
45
+ Tokens are scoped to exactly one group. Raising here surfaces a
46
+ misuse (e.g. attributing a write to a multi-group user session)
47
+ rather than silently picking one.
48
+ """
49
+ if len(self.group_ids) != 1:
50
+ raise ValueError(
51
+ "primary_group_id requires exactly one accessible group"
52
+ )
53
+ return next(iter(self.group_ids))
54
+
55
+
56
+ def scope_session(session: Session, group_ids: frozenset[uuid.UUID]) -> None:
57
+ """Bind a session to a principal's accessible groups for the request."""
58
+ session.info[SCOPE_KEY] = group_ids
59
+
60
+
61
+ @event.listens_for(Session, "do_orm_execute")
62
+ def _apply_group_scope(execute_state) -> None:
63
+ """Inject a group-scoped predicate on every scoped SELECT.
64
+
65
+ Skips relationship/column loads so navigating ``finding.repo`` and
66
+ similar lazy loads still work; only top-level entity SELECTs are filtered.
67
+ """
68
+ if (
69
+ not execute_state.is_select
70
+ or execute_state.is_column_load
71
+ or execute_state.is_relationship_load
72
+ ):
73
+ return
74
+
75
+ group_ids = execute_state.session.info.get(SCOPE_KEY)
76
+ if group_ids is None:
77
+ return
78
+
79
+ # Local imports avoid a circular import at module load time.
80
+ from vcs.models.finding import Finding
81
+ from vcs.models.repo import Repo
82
+ from vcs.models.scan import Scan
83
+
84
+ repo_ids = select(Repo.id).where(Repo.group_id.in_(group_ids))
85
+ execute_state.statement = execute_state.statement.options(
86
+ with_loader_criteria(
87
+ Repo, Repo.group_id.in_(group_ids), include_aliases=True
88
+ ),
89
+ with_loader_criteria(
90
+ Finding, Finding.repo_id.in_(repo_ids), include_aliases=True
91
+ ),
92
+ with_loader_criteria(
93
+ Scan, Scan.repo_id.in_(repo_ids), include_aliases=True
94
+ ),
95
+ )
vcs/api/deps.py ADDED
@@ -0,0 +1,161 @@
1
+ """Dependency injection for FastAPI routes."""
2
+
3
+ import uuid
4
+ from collections.abc import Generator
5
+
6
+ from fastapi import Depends, HTTPException, Query, Request, status
7
+ from sqlalchemy.orm import Session
8
+
9
+ from vcs.api.auth import AuthPrincipal, scope_session
10
+ from vcs.api.session import read_session
11
+ from vcs.config import get_session_cookie_name
12
+ from vcs.db import create_session_factory
13
+ from vcs.enums import UserRole
14
+ from vcs.models.group import Group
15
+ from vcs.models.user import User
16
+ from vcs.services.tokens import resolve_token
17
+
18
+ _UNAUTHORIZED = HTTPException(
19
+ status_code=status.HTTP_401_UNAUTHORIZED,
20
+ detail="Authentication required",
21
+ headers={"WWW-Authenticate": "Bearer"},
22
+ )
23
+
24
+ _session_factory = None
25
+
26
+
27
+ def _get_session_factory():
28
+ global _session_factory
29
+ if _session_factory is None:
30
+ _session_factory = create_session_factory()
31
+ return _session_factory
32
+
33
+
34
+ def get_db() -> Generator[Session, None, None]:
35
+ """Yield an unscoped DB session, closing it after the request.
36
+
37
+ Used for the token lookup itself (which must not be group-scoped) and
38
+ for the ingest write path (which scopes explicitly via the principal).
39
+ """
40
+ session = _get_session_factory()()
41
+ try:
42
+ yield session
43
+ finally:
44
+ session.close()
45
+
46
+
47
+ def get_principal(
48
+ request: Request,
49
+ db: Session = Depends(get_db),
50
+ ) -> AuthPrincipal:
51
+ """Resolve a bearer token OR a session cookie into an AuthPrincipal.
52
+
53
+ Machines send ``Authorization: Bearer <token>`` (one group); humans send
54
+ the dashboard session cookie (their group memberships). Both raise 401
55
+ when absent or invalid.
56
+ """
57
+ authorization = request.headers.get("authorization")
58
+ if authorization and authorization.startswith("Bearer "):
59
+ token = resolve_token(db, authorization[len("Bearer ") :].strip())
60
+ if token is None:
61
+ raise _UNAUTHORIZED
62
+ return AuthPrincipal(
63
+ kind="token",
64
+ org_id=token.group.org_id,
65
+ group_ids=frozenset({token.group_id}),
66
+ token_id=token.id,
67
+ )
68
+
69
+ cookie = request.cookies.get(get_session_cookie_name())
70
+ if cookie:
71
+ user_id = read_session(cookie)
72
+ if user_id:
73
+ user = db.query(User).filter_by(id=user_id).first()
74
+ if user is not None:
75
+ return AuthPrincipal(
76
+ kind="user",
77
+ org_id=user.org_id,
78
+ group_ids=_visible_group_ids(db, user),
79
+ user_id=user.id,
80
+ )
81
+
82
+ raise _UNAUTHORIZED
83
+
84
+
85
+ def _visible_group_ids(db: Session, user: User) -> frozenset[uuid.UUID]:
86
+ """Groups a user may read.
87
+
88
+ Org admins see every group in their org; everyone else sees only their
89
+ memberships. This is the single rule that distinguishes the admin view —
90
+ routes and the isolation net are unchanged.
91
+ """
92
+ if user.role == UserRole.ORG_ADMIN:
93
+ rows = db.query(Group.id).filter_by(org_id=user.org_id).all()
94
+ return frozenset(gid for (gid,) in rows)
95
+ return frozenset(g.id for g in user.groups)
96
+
97
+
98
+ def get_current_user(
99
+ principal: AuthPrincipal = Depends(get_principal),
100
+ db: Session = Depends(get_db),
101
+ ) -> User:
102
+ """Require a human (session) principal and return the User.
103
+
104
+ Machine tokens get 403 — token management and identity endpoints are
105
+ human-only.
106
+ """
107
+ if principal.kind != "user" or principal.user_id is None:
108
+ raise HTTPException(
109
+ status_code=status.HTTP_403_FORBIDDEN,
110
+ detail="This endpoint requires a logged-in user session",
111
+ )
112
+ user = db.query(User).filter_by(id=principal.user_id).first()
113
+ if user is None:
114
+ raise _UNAUTHORIZED
115
+ return user
116
+
117
+
118
+ def resolve_accessible_group(
119
+ db: Session, principal: AuthPrincipal, group: str
120
+ ) -> uuid.UUID:
121
+ """Resolve a group slug/id to an id within the principal's reach, or 404.
122
+
123
+ 404 (not 403) so we never reveal that a group exists outside the
124
+ principal's access.
125
+ """
126
+ query = db.query(Group.id).filter(
127
+ Group.id.in_(principal.accessible_group_ids)
128
+ )
129
+ try:
130
+ query = query.filter(Group.id == uuid.UUID(group))
131
+ except ValueError:
132
+ query = query.filter(Group.slug == group)
133
+ row = query.first()
134
+ if row is None:
135
+ raise HTTPException(status_code=404, detail="Group not found")
136
+ return row[0]
137
+
138
+
139
+ def get_scoped_db(
140
+ principal: AuthPrincipal = Depends(get_principal),
141
+ group: str | None = Query(
142
+ default=None,
143
+ description="Narrow to one accessible group (slug or id).",
144
+ ),
145
+ ) -> Generator[Session, None, None]:
146
+ """Yield a session scoped to the principal's accessible groups.
147
+
148
+ The session carries the groups in ``info`` so the ``do_orm_execute`` net
149
+ filters every read to them. An optional ``group`` query param narrows the
150
+ scope to one accessible group — so the same filter applies uniformly to
151
+ every read endpoint without per-route plumbing.
152
+ """
153
+ session = _get_session_factory()()
154
+ scope = principal.accessible_group_ids
155
+ if group:
156
+ scope = frozenset({resolve_accessible_group(session, principal, group)})
157
+ scope_session(session, scope)
158
+ try:
159
+ yield session
160
+ finally:
161
+ session.close()
@@ -0,0 +1 @@
1
+ """API route modules."""
@@ -0,0 +1,79 @@
1
+ """Analytics endpoints — trends and scanner breakdowns."""
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+
5
+ from fastapi import APIRouter, Depends
6
+ from sqlalchemy import Date, cast, func
7
+ from sqlalchemy.orm import Session
8
+
9
+ from vcs.api.deps import get_scoped_db
10
+ from vcs.api.schemas import (
11
+ AnalyticsScannersResponse,
12
+ AnalyticsTrendsResponse,
13
+ ScannerBreakdown,
14
+ TrendPoint,
15
+ )
16
+ from vcs.enums import FindingStatus
17
+ from vcs.models.finding import Finding
18
+
19
+ router = APIRouter(prefix="/api/v1/analytics")
20
+
21
+
22
+ @router.get("/trends", response_model=AnalyticsTrendsResponse)
23
+ def get_trends(
24
+ days: int = 30, db: Session = Depends(get_scoped_db)
25
+ ) -> AnalyticsTrendsResponse:
26
+ """Return daily open/resolved finding counts over the given window."""
27
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days)
28
+ rows = (
29
+ db.query(
30
+ cast(Finding.first_seen_at, Date).label("date"),
31
+ Finding.status,
32
+ func.count(Finding.id).label("count"),
33
+ )
34
+ .filter(Finding.first_seen_at >= cutoff)
35
+ .group_by("date", Finding.status)
36
+ .order_by("date")
37
+ .all()
38
+ )
39
+ date_map: dict[str, dict[str, int]] = {}
40
+ for row in rows:
41
+ d = str(row.date)
42
+ if d not in date_map:
43
+ date_map[d] = {"open": 0, "resolved": 0}
44
+ status_val = (
45
+ row.status.value
46
+ if hasattr(row.status, "value")
47
+ else str(row.status)
48
+ )
49
+ if status_val == "open":
50
+ date_map[d]["open"] += row.count
51
+ elif status_val == "resolved":
52
+ date_map[d]["resolved"] += row.count
53
+ data = [
54
+ TrendPoint(date=d, open=v["open"], resolved=v["resolved"])
55
+ for d, v in sorted(date_map.items())
56
+ ]
57
+ return AnalyticsTrendsResponse(data=data)
58
+
59
+
60
+ @router.get("/scanners", response_model=AnalyticsScannersResponse)
61
+ def get_scanner_breakdown(
62
+ db: Session = Depends(get_scoped_db),
63
+ ) -> AnalyticsScannersResponse:
64
+ """Return open finding counts grouped by scanner."""
65
+ rows = (
66
+ db.query(
67
+ Finding.scanner,
68
+ func.count(Finding.id).label("count"),
69
+ )
70
+ .filter(Finding.status == FindingStatus.OPEN)
71
+ .group_by(Finding.scanner)
72
+ .order_by(func.count(Finding.id).desc())
73
+ .all()
74
+ )
75
+ data = [
76
+ ScannerBreakdown(scanner=row.scanner, count=row.count)
77
+ for row in rows
78
+ ]
79
+ return AnalyticsScannersResponse(data=data)
vcs/api/routes/auth.py ADDED
@@ -0,0 +1,169 @@
1
+ """Authentication routes — OIDC SSO login and session identity.
2
+
3
+ Login flow (browser):
4
+ GET /api/v1/auth/login -> redirect to the IdP
5
+ GET /api/v1/auth/callback -> exchange code, provision user, set session
6
+ POST /api/v1/auth/logout -> clear session
7
+ GET /api/v1/auth/me -> current user (session principal)
8
+
9
+ The OIDC handshake needs a configured IdP (VCS_OIDC_* env). The session,
10
+ provisioning, and /me/logout paths work independently and are unit-tested.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from authlib.integrations.base_client.errors import OAuthError
16
+ from authlib.integrations.starlette_client import OAuth
17
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
18
+ from fastapi.responses import JSONResponse, RedirectResponse
19
+ from sqlalchemy.orm import Session
20
+
21
+ from vcs.api.deps import get_current_user, get_db
22
+ from vcs.api.session import issue_session
23
+ from vcs.config import (
24
+ dev_login_enabled,
25
+ get_dashboard_url,
26
+ get_oidc_config,
27
+ get_session_cookie_name,
28
+ get_session_ttl_seconds,
29
+ )
30
+ from vcs.models.user import User
31
+ from vcs.services.provisioning import provision_user_from_claims
32
+
33
+ router = APIRouter(prefix="/api/v1")
34
+
35
+ oauth = OAuth()
36
+ _OIDC_NAME = "oidc"
37
+ _registered = False
38
+
39
+
40
+ def _ensure_oidc_registered() -> bool:
41
+ """Register the OIDC client once if configured. Returns availability."""
42
+ global _registered
43
+ cfg = get_oidc_config()
44
+ if not cfg["issuer"] or not cfg["client_id"]:
45
+ return False
46
+ if not _registered:
47
+ issuer = cfg["issuer"].rstrip("/")
48
+ oauth.register(
49
+ name=_OIDC_NAME,
50
+ client_id=cfg["client_id"],
51
+ client_secret=cfg["client_secret"],
52
+ server_metadata_url=(
53
+ f"{issuer}/.well-known/openid-configuration"
54
+ ),
55
+ # No "groups" scope — Entra delivers group membership via the
56
+ # `groups` claim (groupMembershipClaims), not an OAuth scope.
57
+ client_kwargs={"scope": "openid email profile"},
58
+ )
59
+ _registered = True
60
+ return True
61
+
62
+
63
+ def _set_session_cookie(response, user_id: str) -> None:
64
+ response.set_cookie(
65
+ key=get_session_cookie_name(),
66
+ value=issue_session(user_id),
67
+ httponly=True,
68
+ samesite="lax",
69
+ max_age=get_session_ttl_seconds(),
70
+ path="/",
71
+ )
72
+
73
+
74
+ @router.get("/auth/login")
75
+ async def login(request: Request):
76
+ """Begin OIDC login by redirecting to the IdP."""
77
+ if not _ensure_oidc_registered():
78
+ raise HTTPException(
79
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
80
+ detail="SSO is not configured",
81
+ )
82
+ cfg = get_oidc_config()
83
+ client = getattr(oauth, _OIDC_NAME)
84
+ return await client.authorize_redirect(request, cfg["redirect_uri"])
85
+
86
+
87
+ @router.get("/auth/callback")
88
+ async def callback(request: Request, db: Session = Depends(get_db)):
89
+ """Handle the IdP redirect: provision the user and set the session."""
90
+ if not _ensure_oidc_registered():
91
+ raise HTTPException(
92
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
93
+ detail="SSO is not configured",
94
+ )
95
+ cfg = get_oidc_config()
96
+ client = getattr(oauth, _OIDC_NAME)
97
+ try:
98
+ token = await client.authorize_access_token(request)
99
+ except OAuthError as exc:
100
+ # Surface IdP errors cleanly instead of a 500.
101
+ raise HTTPException(
102
+ status_code=status.HTTP_400_BAD_REQUEST,
103
+ detail=f"SSO login failed: {exc.description or exc.error}",
104
+ ) from exc
105
+ claims = token.get("userinfo") or token.get("id_token_claims") or {}
106
+
107
+ user = provision_user_from_claims(
108
+ db,
109
+ dict(claims),
110
+ org_slug=cfg["default_org_slug"],
111
+ groups_claim=cfg["groups_claim"],
112
+ )
113
+ db.commit()
114
+
115
+ response = RedirectResponse(url=get_dashboard_url())
116
+ _set_session_cookie(response, str(user.id))
117
+ return response
118
+
119
+
120
+ @router.get("/auth/dev-login")
121
+ def dev_login(db: Session = Depends(get_db)):
122
+ """Dev-only: issue a session for a seeded admin user (no IdP needed).
123
+
124
+ Guarded by VCS_DEV_LOGIN; returns 404 when disabled so it is inert in
125
+ production. Useful for local dashboard preview before SSO is configured.
126
+ """
127
+ if not dev_login_enabled():
128
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
129
+ cfg = get_oidc_config()
130
+ user = provision_user_from_claims(
131
+ db,
132
+ {
133
+ "sub": "dev-login",
134
+ "email": "dev@localhost",
135
+ "name": "Dev User",
136
+ "role": "org_admin",
137
+ "groups": ["platform"],
138
+ },
139
+ org_slug=cfg["default_org_slug"],
140
+ groups_claim="groups",
141
+ )
142
+ db.commit()
143
+ response = RedirectResponse(url=get_dashboard_url())
144
+ _set_session_cookie(response, str(user.id))
145
+ return response
146
+
147
+
148
+ @router.post("/auth/logout")
149
+ def logout() -> JSONResponse:
150
+ """Clear the session cookie."""
151
+ response = JSONResponse({"status": "logged_out"})
152
+ response.delete_cookie(get_session_cookie_name(), path="/")
153
+ return response
154
+
155
+
156
+ @router.get("/auth/me")
157
+ def me(user: User = Depends(get_current_user)) -> dict:
158
+ """Return the current logged-in user."""
159
+ return {
160
+ "id": str(user.id),
161
+ "email": user.email,
162
+ "display_name": user.display_name,
163
+ "role": user.role.value,
164
+ "org_id": str(user.org_id),
165
+ "groups": [
166
+ {"id": str(g.id), "slug": g.slug, "name": g.name}
167
+ for g in user.groups
168
+ ],
169
+ }