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.
- vcs/__init__.py +1 -0
- vcs/api/__init__.py +1 -0
- vcs/api/app.py +92 -0
- vcs/api/auth.py +95 -0
- vcs/api/deps.py +161 -0
- vcs/api/routes/__init__.py +1 -0
- vcs/api/routes/analytics.py +79 -0
- vcs/api/routes/auth.py +169 -0
- vcs/api/routes/findings.py +141 -0
- vcs/api/routes/groups.py +122 -0
- vcs/api/routes/health.py +11 -0
- vcs/api/routes/ingest.py +49 -0
- vcs/api/routes/overview.py +95 -0
- vcs/api/routes/repos.py +106 -0
- vcs/api/routes/scans.py +79 -0
- vcs/api/routes/tokens.py +178 -0
- vcs/api/schemas.py +172 -0
- vcs/api/services/__init__.py +1 -0
- vcs/api/services/auto_resolve.py +55 -0
- vcs/api/services/ingest.py +205 -0
- vcs/api/session.py +32 -0
- vcs/cli/__init__.py +1 -0
- vcs/cli/admin.py +177 -0
- vcs/cli/app.py +19 -0
- vcs/cli/output.py +68 -0
- vcs/cli/scan_command.py +195 -0
- vcs/client/__init__.py +1 -0
- vcs/client/api.py +108 -0
- vcs/config.py +150 -0
- vcs/db.py +47 -0
- vcs/enums.py +55 -0
- vcs/fingerprint.py +17 -0
- vcs/models/__init__.py +22 -0
- vcs/models/api_token.py +39 -0
- vcs/models/base.py +32 -0
- vcs/models/finding.py +81 -0
- vcs/models/finding_history.py +42 -0
- vcs/models/group.py +35 -0
- vcs/models/organization.py +23 -0
- vcs/models/repo.py +56 -0
- vcs/models/scan.py +62 -0
- vcs/models/user.py +53 -0
- vcs/scanner/__init__.py +1 -0
- vcs/scanner/models.py +67 -0
- vcs/scanner/parser.py +25 -0
- vcs/scanner/runner.py +146 -0
- vcs/services/__init__.py +1 -0
- vcs/services/provisioning.py +144 -0
- vcs/services/tokens.py +74 -0
- vcs/storage/__init__.py +55 -0
- vcs/storage/memory.py +19 -0
- vcs/storage/s3.py +49 -0
- vcs/worker/__init__.py +1 -0
- vcs/worker/celery_app.py +17 -0
- vcs/worker/db.py +33 -0
- vcs/worker/tasks.py +48 -0
- vehlo_code_scanner-0.1.1rc1.dist-info/METADATA +198 -0
- vehlo_code_scanner-0.1.1rc1.dist-info/RECORD +60 -0
- vehlo_code_scanner-0.1.1rc1.dist-info/WHEEL +4 -0
- 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
|
+
}
|