Skip to content

Commit 61666a1

Browse files
EfeDurmaz16claude
andcommitted
fix(cli): add goreleaser v2 config version
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent daa83ad commit 61666a1

8 files changed

Lines changed: 571 additions & 2 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
-- Access audit log for SOC 2 compliance.
2+
-- Records all authentication events, admin actions, and API access.
3+
4+
CREATE TABLE IF NOT EXISTS access_audit_log (
5+
id BIGSERIAL PRIMARY KEY,
6+
event_type TEXT NOT NULL, -- 'auth_success', 'auth_failure', 'admin_action', 'api_access'
7+
user_id TEXT,
8+
org_id TEXT,
9+
ip_address TEXT,
10+
user_agent TEXT,
11+
endpoint TEXT,
12+
method TEXT,
13+
status_code INT,
14+
auth_method TEXT, -- 'api_key', 'jwt', 'anonymous'
15+
details JSONB DEFAULT '{}',
16+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
17+
);
18+
19+
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON access_audit_log (created_at);
20+
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON access_audit_log (user_id) WHERE user_id IS NOT NULL;
21+
CREATE INDEX IF NOT EXISTS idx_audit_log_event_type ON access_audit_log (event_type);
22+
CREATE INDEX IF NOT EXISTS idx_audit_log_org_id ON access_audit_log (org_id) WHERE org_id IS NOT NULL;
23+
24+
-- Retention: keep for 1 year
25+
-- DELETE FROM access_audit_log WHERE created_at < now() - interval '1 year';
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Access audit logging for SOC 2 compliance.
2+
3+
Logs all authentication events, admin actions, and sensitive API access
4+
to the access_audit_log table.
5+
"""
6+
from __future__ import annotations
7+
8+
import json
9+
import logging
10+
from typing import Any, Optional
11+
12+
from fastapi import Request
13+
14+
logger = logging.getLogger("sardis.audit")
15+
16+
17+
async def log_access_event(
18+
event_type: str,
19+
request: Optional[Request] = None,
20+
user_id: str | None = None,
21+
org_id: str | None = None,
22+
status_code: int | None = None,
23+
auth_method: str | None = None,
24+
details: dict[str, Any] | None = None,
25+
) -> None:
26+
"""Record an access event to the audit log table.
27+
28+
Best-effort — failures are logged but do not block the request.
29+
"""
30+
ip_address = ""
31+
user_agent = ""
32+
endpoint = ""
33+
method = ""
34+
35+
if request:
36+
if request.client:
37+
ip_address = request.client.host
38+
user_agent = request.headers.get("User-Agent", "")[:500]
39+
endpoint = str(request.url.path)
40+
method = request.method
41+
42+
try:
43+
from sardis_v2_core.database import get_pool
44+
45+
pool = await get_pool()
46+
async with pool.acquire() as conn:
47+
await conn.execute(
48+
"""
49+
INSERT INTO access_audit_log
50+
(event_type, user_id, org_id, ip_address, user_agent,
51+
endpoint, method, status_code, auth_method, details)
52+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
53+
""",
54+
event_type,
55+
user_id,
56+
org_id,
57+
ip_address,
58+
user_agent[:500],
59+
endpoint[:500],
60+
method,
61+
status_code,
62+
auth_method,
63+
json.dumps(details or {}),
64+
)
65+
except Exception as e:
66+
# Best-effort — don't let audit logging break requests
67+
logger.warning("Audit log write failed: %s", e)
68+
69+
70+
async def log_auth_success(
71+
request: Request,
72+
user_id: str,
73+
org_id: str,
74+
auth_method: str,
75+
) -> None:
76+
"""Log a successful authentication."""
77+
await log_access_event(
78+
event_type="auth_success",
79+
request=request,
80+
user_id=user_id,
81+
org_id=org_id,
82+
auth_method=auth_method,
83+
)
84+
85+
86+
async def log_auth_failure(
87+
request: Request,
88+
auth_method: str,
89+
reason: str = "",
90+
) -> None:
91+
"""Log a failed authentication attempt."""
92+
await log_access_event(
93+
event_type="auth_failure",
94+
request=request,
95+
auth_method=auth_method,
96+
details={"reason": reason},
97+
)
98+
99+
100+
async def log_admin_action(
101+
request: Request,
102+
user_id: str,
103+
org_id: str,
104+
action: str,
105+
details: dict[str, Any] | None = None,
106+
) -> None:
107+
"""Log an admin action for compliance."""
108+
await log_access_event(
109+
event_type="admin_action",
110+
request=request,
111+
user_id=user_id,
112+
org_id=org_id,
113+
auth_method="admin",
114+
details={"action": action, **(details or {})},
115+
)
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""MFA enforcement for admin endpoints.
2+
3+
Verifies TOTP codes via X-MFA-Code header when users have MFA enabled.
4+
In production, admin endpoints with sensitive operations require MFA.
5+
"""
6+
from __future__ import annotations
7+
8+
import logging
9+
import os
10+
11+
from fastapi import Depends, HTTPException, Request, status
12+
13+
from sardis_api.authz import Principal, require_admin_principal
14+
15+
logger = logging.getLogger("sardis.api.mfa")
16+
17+
18+
async def _get_user_mfa_status(user_id: str) -> dict:
19+
"""Check if a user has MFA enabled and retrieve their secret."""
20+
try:
21+
from sardis_v2_core.database import get_pool
22+
23+
pool = await get_pool()
24+
async with pool.acquire() as conn:
25+
row = await conn.fetchrow(
26+
"SELECT mfa_enabled, mfa_secret FROM users WHERE id = $1",
27+
user_id,
28+
)
29+
if not row:
30+
return {"enabled": False, "secret": None}
31+
return {
32+
"enabled": bool(row["mfa_enabled"]),
33+
"secret": row["mfa_secret"],
34+
}
35+
except Exception as e:
36+
logger.warning("Could not check MFA status for user=%s: %s", user_id, e)
37+
return {"enabled": False, "secret": None}
38+
39+
40+
async def require_mfa_if_enabled(
41+
request: Request,
42+
principal: Principal = Depends(require_admin_principal),
43+
) -> None:
44+
"""FastAPI dependency that enforces MFA for admin users who have it enabled.
45+
46+
Checks for X-MFA-Code header and verifies against the user's TOTP secret.
47+
Skips verification if MFA is not enabled for the user or in dev/test environments
48+
(unless SARDIS_REQUIRE_ADMIN_MFA=1 is set).
49+
"""
50+
env = os.getenv("SARDIS_ENVIRONMENT", "dev").strip().lower()
51+
force_mfa = os.getenv("SARDIS_REQUIRE_ADMIN_MFA", "").strip().lower() in (
52+
"1", "true", "yes", "on",
53+
)
54+
55+
# In dev/test, skip MFA unless explicitly forced
56+
if env not in ("prod", "production") and not force_mfa:
57+
return
58+
59+
# Only enforce MFA for JWT-authenticated users (not API keys)
60+
if principal.kind != "jwt":
61+
return
62+
63+
user = principal.user
64+
if user is None:
65+
return
66+
67+
user_id = getattr(user, "username", None) or getattr(user, "id", None)
68+
if not user_id:
69+
return
70+
71+
mfa_status = await _get_user_mfa_status(user_id)
72+
if not mfa_status["enabled"]:
73+
if env in ("prod", "production"):
74+
logger.warning(
75+
"Admin user %s has no MFA enabled — consider enforcing MFA setup",
76+
user_id,
77+
)
78+
return
79+
80+
# MFA is enabled — require the code
81+
mfa_code = request.headers.get("X-MFA-Code", "").strip()
82+
if not mfa_code:
83+
raise HTTPException(
84+
status_code=status.HTTP_403_FORBIDDEN,
85+
detail="MFA code required. Provide X-MFA-Code header.",
86+
)
87+
88+
# Verify TOTP code
89+
try:
90+
import pyotp
91+
92+
totp = pyotp.TOTP(mfa_status["secret"])
93+
if not totp.verify(mfa_code, valid_window=1):
94+
logger.warning("Invalid MFA code for admin user %s", user_id)
95+
raise HTTPException(
96+
status_code=status.HTTP_403_FORBIDDEN,
97+
detail="Invalid MFA code",
98+
)
99+
except ImportError:
100+
logger.error("pyotp not installed — MFA verification unavailable")
101+
raise HTTPException(
102+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
103+
detail="MFA verification unavailable",
104+
)
105+
106+
logger.info("MFA verified for admin user %s", user_id)

0 commit comments

Comments
 (0)