@ripla/godd-mcp 1.0.3 → 1.0.4-canary.10

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.
@@ -2,13 +2,22 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import logging
6
7
  import re
8
+ from typing import Annotated
7
9
  from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
8
10
 
9
- from pydantic import AliasChoices, Field, model_validator
11
+ from pydantic import AliasChoices, BeforeValidator, Field, model_validator
10
12
  from pydantic_settings import BaseSettings, SettingsConfigDict
11
13
 
14
+
15
+ def _empty_str_to_none(v: object) -> object:
16
+ """Convert empty string to None for optional int fields from env vars."""
17
+ if v == "":
18
+ return None
19
+ return v
20
+
12
21
  _logger = logging.getLogger(__name__)
13
22
 
14
23
 
@@ -72,9 +81,9 @@ class Settings(BaseSettings):
72
81
  github_branch: str = "main"
73
82
  github_bot_name: str = "godd-notes-bot"
74
83
  github_bot_email: str = "notes-bot@ripla.co.jp"
75
- github_app_id: int | None = None
84
+ github_app_id: Annotated[int | None, BeforeValidator(_empty_str_to_none)] = None
76
85
  github_app_private_key: str | None = None
77
- github_app_installation_id: int | None = None
86
+ github_app_installation_id: Annotated[int | None, BeforeValidator(_empty_str_to_none)] = None
78
87
  docs_path: str = "docs"
79
88
 
80
89
  # Trusted proxies (comma-separated CIDRs/IPs that may set X-Forwarded-For)
@@ -84,6 +93,13 @@ class Settings(BaseSettings):
84
93
  notes_admin_username: str = "admin"
85
94
  notes_admin_password: str = "admin123"
86
95
 
96
+ # IAM principal login (REQ-741)
97
+ notes_iam_login_enabled: bool = False
98
+ # JSON string: { "<principalArn>": "<notesUsername>" }
99
+ notes_iam_principal_user_map: str | None = None
100
+ # Comma-separated additional allowlist of principal ARNs
101
+ notes_iam_admin_principal_arns: str | None = None
102
+
87
103
  # Testing (skip DB seed when True)
88
104
  testing: bool = False
89
105
 
@@ -134,6 +150,31 @@ class Settings(BaseSettings):
134
150
  first_origin = self.allowed_origins.split(",")[0].strip()
135
151
  return first_origin.startswith("https://")
136
152
 
153
+ @property
154
+ def iam_principal_user_map(self) -> dict[str, str]:
155
+ """Parse NOTES_IAM_PRINCIPAL_USER_MAP JSON into a dict."""
156
+ if not self.notes_iam_principal_user_map:
157
+ return {}
158
+ try:
159
+ data = json.loads(self.notes_iam_principal_user_map)
160
+ if isinstance(data, dict):
161
+ return {str(k): str(v) for k, v in data.items()}
162
+ except (json.JSONDecodeError, ValueError):
163
+ _logger.warning(
164
+ "NOTES_IAM_PRINCIPAL_USER_MAP is not valid JSON "
165
+ "— IAM login will reject all principals"
166
+ )
167
+ return {}
168
+
169
+ @property
170
+ def iam_admin_principal_arns(self) -> frozenset[str]:
171
+ """Parse NOTES_IAM_ADMIN_PRINCIPAL_ARNS into a frozenset of ARN strings."""
172
+ if not self.notes_iam_admin_principal_arns:
173
+ return frozenset()
174
+ return frozenset(
175
+ a.strip() for a in self.notes_iam_admin_principal_arns.split(",") if a.strip()
176
+ )
177
+
137
178
  @property
138
179
  def trusted_proxy_set(self) -> frozenset[str]:
139
180
  """Parse TRUSTED_PROXIES into a set of IP addresses."""
@@ -168,6 +209,10 @@ class Settings(BaseSettings):
168
209
  warnings.append("DB_PASSWORD is using the default dev value")
169
210
  if self.allowed_origins == "*":
170
211
  warnings.append("ALLOWED_ORIGINS is set to '*' (all origins)")
212
+ if self.notes_iam_login_enabled and not self.notes_iam_principal_user_map:
213
+ warnings.append(
214
+ "NOTES_IAM_LOGIN_ENABLED=true but NOTES_IAM_PRINCIPAL_USER_MAP is not set"
215
+ )
171
216
  for w in warnings:
172
217
  _logger.warning("SECURITY: %s — override via environment variable for production", w)
173
218
  return self
@@ -1,4 +1,4 @@
1
- """Auth router: login, refresh, logout, me, change-password."""
1
+ """Auth router: login, refresh, logout, me, change-password, IAM login."""
2
2
 
3
3
  import uuid
4
4
  from datetime import UTC, datetime, timedelta
@@ -11,7 +11,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
11
11
  from app.config import settings
12
12
  from app.database import get_db
13
13
  from app.models import NotesSetting, NotesUser
14
- from app.schemas.auth import ChangePasswordRequest, LoginRequest, TokenResponse, UserInfo
14
+ from app.schemas.auth import (
15
+ ChangePasswordRequest,
16
+ IamChallengeResponse,
17
+ IamLoginRequest,
18
+ LoginRequest,
19
+ TokenResponse,
20
+ UserInfo,
21
+ )
15
22
  from app.security import (
16
23
  auth_dependency,
17
24
  create_access_token,
@@ -22,6 +29,15 @@ from app.security import (
22
29
  verify_password,
23
30
  )
24
31
  from app.services.audit import audit_log
32
+ from app.services.iam_auth import (
33
+ IAM_CHALLENGE_TTL_SECONDS,
34
+ consume_nonce,
35
+ generate_nonce,
36
+ is_nonce_signed,
37
+ normalize_principal,
38
+ store_nonce,
39
+ verify_sts_identity,
40
+ )
25
41
 
26
42
  router = APIRouter(prefix="/api/auth", tags=["auth"])
27
43
 
@@ -257,3 +273,165 @@ async def issue_ws_ticket(
257
273
  """Issue a short-lived one-time ticket for WebSocket authentication."""
258
274
  ticket = create_ws_ticket(payload)
259
275
  return {"ticket": ticket}
276
+
277
+
278
+ @router.post("/iam/challenge", response_model=IamChallengeResponse)
279
+ async def iam_challenge():
280
+ """Issue a one-time nonce for an IAM login attempt.
281
+
282
+ The caller must include this nonce as the ``X-Godd-Nonce`` header
283
+ in the SigV4-signed STS ``GetCallerIdentity`` request sent to
284
+ ``/api/auth/iam-login``.
285
+
286
+ Returns 404 when ``NOTES_IAM_LOGIN_ENABLED`` is not ``true``.
287
+ """
288
+ if not settings.notes_iam_login_enabled:
289
+ raise HTTPException(status_code=404, detail="IAM login is not enabled")
290
+ nonce = generate_nonce()
291
+ store_nonce(nonce)
292
+ return IamChallengeResponse(nonce=nonce, expires_in=IAM_CHALLENGE_TTL_SECONDS)
293
+
294
+
295
+ @router.post("/iam-login", response_model=TokenResponse)
296
+ async def iam_login(
297
+ request: Request,
298
+ body: IamLoginRequest,
299
+ db: AsyncSession = Depends(get_db),
300
+ ):
301
+ """Verify a SigV4-signed STS GetCallerIdentity request and issue a Notes JWT.
302
+
303
+ Flow:
304
+ 1. Validate and consume the nonce (TTL + 1-time).
305
+ 2. Confirm the nonce header is covered by the SigV4 signature.
306
+ 3. Forward the signed request to AWS STS; parse the caller ARN.
307
+ 4. Normalize the ARN (assumed-role → IAM role).
308
+ 5. Verify the principal is in NOTES_IAM_PRINCIPAL_USER_MAP
309
+ and optionally in NOTES_IAM_ADMIN_PRINCIPAL_ARNS.
310
+ 6. Look up the corresponding Notes user; check lockout.
311
+ 7. Issue JWT access token + refresh cookie (same as password login).
312
+
313
+ Returns 404 when ``NOTES_IAM_LOGIN_ENABLED`` is not ``true``.
314
+ """
315
+ if not settings.notes_iam_login_enabled:
316
+ raise HTTPException(status_code=404, detail="IAM login is not enabled")
317
+
318
+ ip = _resolve_client_ip(request)
319
+ headers_lower = {k.lower(): v for k, v in body.signed_headers.items()}
320
+
321
+ # --- Step 1: consume nonce (TTL + 1-time) ---
322
+ nonce = headers_lower.get("x-godd-nonce")
323
+ if not nonce:
324
+ await audit_log(
325
+ db, None, "login_failed", ip,
326
+ {"auth_method": "iam", "reason": "missing_nonce"},
327
+ )
328
+ raise HTTPException(status_code=401, detail="Invalid IAM login challenge")
329
+
330
+ if not consume_nonce(nonce):
331
+ await audit_log(
332
+ db, None, "login_failed", ip,
333
+ {"auth_method": "iam", "reason": "invalid_nonce"},
334
+ )
335
+ raise HTTPException(status_code=401, detail="Invalid IAM login challenge")
336
+
337
+ # --- Step 2: verify nonce is in the SigV4 SignedHeaders ---
338
+ auth_header = headers_lower.get("authorization", "")
339
+ if not is_nonce_signed(auth_header):
340
+ await audit_log(
341
+ db, None, "login_failed", ip,
342
+ {"auth_method": "iam", "reason": "nonce_not_signed"},
343
+ )
344
+ raise HTTPException(status_code=401, detail="Invalid IAM login challenge")
345
+
346
+ # --- Step 3: forward to AWS STS ---
347
+ try:
348
+ caller_arn = await verify_sts_identity(body.signed_headers, body.region, body.signed_body)
349
+ except ValueError as exc:
350
+ await audit_log(
351
+ db, None, "login_failed", ip,
352
+ {"auth_method": "iam", "reason": "sts_verification_failed"},
353
+ )
354
+ raise HTTPException(status_code=401, detail="Invalid IAM credentials") from exc
355
+
356
+ # --- Step 4: normalize principal ARN ---
357
+ principal_arn = normalize_principal(caller_arn)
358
+
359
+ # --- Step 5a: PRINCIPAL_USER_MAP check ---
360
+ username = settings.iam_principal_user_map.get(principal_arn)
361
+ if not username:
362
+ await audit_log(
363
+ db, None, "login_failed", ip,
364
+ {"auth_method": "iam", "reason": "principal_not_allowed",
365
+ "principal_arn": principal_arn},
366
+ )
367
+ raise HTTPException(status_code=403, detail="IAM principal is not allowed")
368
+
369
+ # --- Step 5b: optional ADMIN_PRINCIPAL_ARNS allowlist check ---
370
+ admin_arns = settings.iam_admin_principal_arns
371
+ if admin_arns and principal_arn not in admin_arns:
372
+ await audit_log(
373
+ db, None, "login_failed", ip,
374
+ {"auth_method": "iam", "reason": "principal_not_in_allowlist",
375
+ "principal_arn": principal_arn},
376
+ )
377
+ raise HTTPException(status_code=403, detail="IAM principal is not allowed")
378
+
379
+ # --- Step 6: look up Notes user and check lockout ---
380
+ result = await db.execute(select(NotesUser).where(NotesUser.username == username))
381
+ user = result.scalar_one_or_none()
382
+ if user is None:
383
+ await audit_log(
384
+ db, None, "login_failed", ip,
385
+ {"auth_method": "iam", "reason": "user_not_found", "principal_arn": principal_arn},
386
+ )
387
+ raise HTTPException(status_code=403, detail="IAM principal is not allowed")
388
+
389
+ if user.locked_until and user.locked_until > datetime.now(UTC):
390
+ remaining = int((user.locked_until - datetime.now(UTC)).total_seconds() / 60)
391
+ await audit_log(
392
+ db, user.id, "login_failed", ip,
393
+ {"auth_method": "iam", "reason": "account_locked"},
394
+ )
395
+ raise HTTPException(
396
+ status_code=423,
397
+ detail=f"Account locked. Try again in {remaining} minutes.",
398
+ )
399
+
400
+ # --- Step 7: issue JWT + refresh cookie ---
401
+ access_token = create_access_token(str(user.id), user.username, user.display_name, user.role)
402
+ refresh_token = str(uuid.uuid4())
403
+ user.refresh_token = hash_refresh_token(refresh_token)
404
+ user.failed_login_attempts = 0
405
+ user.locked_until = None
406
+ await db.flush()
407
+
408
+ await audit_log(
409
+ db, user.id, "login", ip,
410
+ {"auth_method": "iam", "principal_arn": principal_arn},
411
+ )
412
+
413
+ expires_at = datetime.fromtimestamp(
414
+ datetime.now(UTC).timestamp() + settings.jwt_access_expires_min * 60,
415
+ tz=UTC,
416
+ ).isoformat()
417
+ data = TokenResponse(
418
+ access_token=access_token,
419
+ user=UserInfo(
420
+ id=str(user.id),
421
+ username=user.username,
422
+ display_name=user.display_name,
423
+ role=user.role,
424
+ ),
425
+ expires_at=expires_at,
426
+ )
427
+ response = JSONResponse(content=data.model_dump(by_alias=True))
428
+ response.set_cookie(
429
+ key="refresh_token",
430
+ value=refresh_token,
431
+ httponly=True,
432
+ secure=settings.is_cookie_secure,
433
+ samesite="strict",
434
+ path="/api/auth",
435
+ max_age=settings.jwt_refresh_expires_days * 86400,
436
+ )
437
+ return response
@@ -13,6 +13,7 @@ from app.security import auth_dependency, require_role
13
13
  from app.services.github import get_github_config
14
14
  from app.services.github_issues import (
15
15
  create_issue,
16
+ create_label,
16
17
  get_issue,
17
18
  list_issue_labels,
18
19
  list_issues,
@@ -25,6 +26,17 @@ router = APIRouter(prefix="/api/issues", tags=["issues"])
25
26
 
26
27
  MAX_TITLE_LENGTH = 256
27
28
  MAX_BODY_LENGTH = 65_536
29
+ MAX_LABEL_NAME_LENGTH = 50
30
+ MAX_LABEL_DESCRIPTION_LENGTH = 200
31
+ _HEX_COLOR_RE = r"^[0-9a-fA-F]{6}$"
32
+
33
+
34
+ class LabelCreateBody(BaseModel):
35
+ """Label creation request."""
36
+
37
+ name: str = Field(..., min_length=1, max_length=MAX_LABEL_NAME_LENGTH)
38
+ color: str = Field(..., pattern=_HEX_COLOR_RE)
39
+ description: str = Field(default="", max_length=MAX_LABEL_DESCRIPTION_LENGTH)
28
40
 
29
41
 
30
42
  class IssueCreateBody(BaseModel):
@@ -96,6 +108,39 @@ async def list_labels_endpoint(
96
108
  raise HTTPException(status_code=500, detail="Internal server error") from None
97
109
 
98
110
 
111
+ @router.post("/labels", status_code=201)
112
+ async def create_label_endpoint(
113
+ body: LabelCreateBody,
114
+ _payload: dict = Depends(auth_dependency),
115
+ __: dict = Depends(require_role("admin", "editor")),
116
+ ):
117
+ """Create a new label in the repository."""
118
+ if settings.is_mock_mode():
119
+ raise HTTPException(status_code=400, detail="Mock mode: label creation disabled")
120
+ try:
121
+ config = get_github_config()
122
+ return await create_label(
123
+ config,
124
+ name=body.name,
125
+ color=body.color,
126
+ description=body.description,
127
+ )
128
+ except httpx.HTTPStatusError as e:
129
+ if e.response.status_code in (401, 403):
130
+ raise HTTPException(
131
+ status_code=e.response.status_code,
132
+ detail="GitHub token is invalid or expired",
133
+ ) from None
134
+ if e.response.status_code == 422:
135
+ raise HTTPException(
136
+ status_code=422, detail="Label name already exists or invalid data",
137
+ ) from None
138
+ raise HTTPException(status_code=502, detail="GitHub API error") from e
139
+ except Exception:
140
+ logger.exception("Failed to create label")
141
+ raise HTTPException(status_code=500, detail="Internal server error") from None
142
+
143
+
99
144
  @router.get("/{number}")
100
145
  async def get_issue_endpoint(
101
146
  number: int,
@@ -1,7 +1,7 @@
1
1
  """Auth-related Pydantic schemas."""
2
2
 
3
3
 
4
- from pydantic import BaseModel, Field
4
+ from pydantic import BaseModel, Field, field_validator
5
5
 
6
6
 
7
7
  class LoginRequest(BaseModel):
@@ -41,4 +41,36 @@ class ChangePasswordRequest(BaseModel):
41
41
  model_config = {"populate_by_name": True}
42
42
 
43
43
 
44
+ class IamChallengeResponse(BaseModel):
45
+ """Response from IAM login challenge endpoint."""
46
+
47
+ nonce: str
48
+ expires_in: int = 300
49
+
50
+
51
+ class IamLoginRequest(BaseModel):
52
+ """IAM login request: SigV4-signed STS GetCallerIdentity headers."""
53
+
54
+ region: str = Field(default="us-east-1", min_length=1, max_length=50)
55
+ # SigV4-signed headers for STS GetCallerIdentity.
56
+ # Must include: Authorization, X-Amz-Date, X-Godd-Nonce, Content-Type, Host.
57
+ # X-Amz-Security-Token is required for temporary credentials.
58
+ signed_headers: dict[str, str] = Field(..., alias="signedHeaders")
59
+ # Standard STS GetCallerIdentity request body.
60
+ signed_body: str = Field(
61
+ default="Action=GetCallerIdentity&Version=2011-06-15",
62
+ alias="signedBody",
63
+ )
64
+
65
+ model_config = {"populate_by_name": True}
66
+
67
+ @field_validator("region")
68
+ @classmethod
69
+ def validate_region(cls, v: str) -> str:
70
+ import re # noqa: PLC0415
71
+ if not re.match(r"^[a-z]{2}-[a-z]+-\d+$", v):
72
+ raise ValueError("Invalid AWS region format")
73
+ return v
74
+
75
+
44
76
  TokenResponse.model_rebuild()
@@ -117,6 +117,30 @@ async def update_issue(
117
117
  return _format_issue_detail(resp.json())
118
118
 
119
119
 
120
+ async def create_label(
121
+ config: dict,
122
+ *,
123
+ name: str,
124
+ color: str,
125
+ description: str = "",
126
+ ) -> dict:
127
+ """Create a new label in the repository."""
128
+ client = await get_github_client()
129
+ token = await resolve_token(config)
130
+ resp = await client.post(
131
+ f"{_repo_url(config)}/labels",
132
+ headers=_auth_headers(token),
133
+ json={"name": name, "color": color.lstrip("#"), "description": description},
134
+ )
135
+ resp.raise_for_status()
136
+ data = resp.json()
137
+ return {
138
+ "name": data["name"],
139
+ "color": data.get("color", ""),
140
+ "description": data.get("description", ""),
141
+ }
142
+
143
+
120
144
  async def list_issue_labels(config: dict) -> list[dict]:
121
145
  """List all labels for the repository (auto-paginates via Link header)."""
122
146
  token = await resolve_token(config)
@@ -0,0 +1,120 @@
1
+ """IAM principal auth service: nonce management and STS identity verification.
2
+
3
+ Security design (REQ-741):
4
+ - Nonce is cryptographically random (256-bit entropy), short-lived (5 min), 1-time use.
5
+ - Client includes nonce as `X-Godd-Nonce` in the SigV4-signed STS GetCallerIdentity request.
6
+ - Notes API verifies the nonce header is covered by the SigV4 signature before forwarding.
7
+ - Notes API forwards the signed request to AWS STS; only AWS validates the signature.
8
+ - AWS Secret Access Key / session token is never stored or logged by Notes API.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import secrets
15
+ import xml.etree.ElementTree as ET
16
+ from datetime import UTC, datetime, timedelta
17
+
18
+ import httpx
19
+
20
+ IAM_CHALLENGE_TTL_SECONDS: int = 300 # 5 minutes
21
+ NONCE_HEADER_NAME: str = "x-godd-nonce"
22
+ _STS_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
23
+
24
+ # In-memory nonce store: nonce → expiry timestamp.
25
+ # Single-process assumption: each container handles its own challenges.
26
+ _nonce_store: dict[str, datetime] = {}
27
+
28
+
29
+ def generate_nonce() -> str:
30
+ """Generate a cryptographically random URL-safe nonce (~256-bit entropy)."""
31
+ return secrets.token_urlsafe(32)
32
+
33
+
34
+ def store_nonce(nonce: str) -> None:
35
+ """Persist nonce with TTL and purge already-expired entries."""
36
+ now = datetime.now(UTC)
37
+ expired_keys = [k for k, exp in _nonce_store.items() if exp <= now]
38
+ for k in expired_keys:
39
+ del _nonce_store[k]
40
+ _nonce_store[nonce] = now + timedelta(seconds=IAM_CHALLENGE_TTL_SECONDS)
41
+
42
+
43
+ def consume_nonce(nonce: str) -> bool:
44
+ """Atomically verify and remove nonce (1-time use).
45
+
46
+ Returns True only when the nonce exists and has not yet expired.
47
+ """
48
+ expires_at = _nonce_store.pop(nonce, None)
49
+ if expires_at is None:
50
+ return False
51
+ return datetime.now(UTC) < expires_at
52
+
53
+
54
+ def is_nonce_signed(authorization_header: str) -> bool:
55
+ """Return True when the SigV4 Authorization header lists x-godd-nonce in SignedHeaders."""
56
+ m = re.search(r"SignedHeaders=([^,\s]+)", authorization_header, re.IGNORECASE)
57
+ if not m:
58
+ return False
59
+ signed = m.group(1).lower().split(";")
60
+ return NONCE_HEADER_NAME in signed
61
+
62
+
63
+ def normalize_principal(arn: str) -> str:
64
+ """Normalize an STS assumed-role ARN to the corresponding IAM role ARN.
65
+
66
+ arn:aws:sts::<account>:assumed-role/<role>/<session>
67
+ → arn:aws:iam::<account>:role/<role>
68
+
69
+ IAM user ARNs and other formats are returned unchanged.
70
+ """
71
+ m = re.match(r"^arn:aws:sts::(\d+):assumed-role/([^/]+)/", arn)
72
+ if m:
73
+ account, role = m.group(1), m.group(2)
74
+ return f"arn:aws:iam::{account}:role/{role}"
75
+ return arn
76
+
77
+
78
+ async def verify_sts_identity(
79
+ signed_headers: dict[str, str],
80
+ region: str,
81
+ signed_body: str,
82
+ ) -> str:
83
+ """Forward a SigV4-signed STS GetCallerIdentity request and return the caller ARN.
84
+
85
+ Raises ValueError with a short reason string on any failure.
86
+ AWS credentials (key, secret, token) are never inspected or stored by this function.
87
+ """
88
+ sts_url = f"https://sts.{region}.amazonaws.com/"
89
+ try:
90
+ async with httpx.AsyncClient(timeout=_STS_TIMEOUT) as client:
91
+ resp = await client.post(
92
+ sts_url,
93
+ headers=signed_headers,
94
+ content=signed_body.encode(),
95
+ )
96
+ except httpx.HTTPError as exc:
97
+ raise ValueError("sts_network_error") from exc
98
+
99
+ if resp.status_code != 200:
100
+ raise ValueError("sts_verification_failed")
101
+
102
+ return _parse_sts_arn(resp.text)
103
+
104
+
105
+ def _parse_sts_arn(xml_text: str) -> str:
106
+ """Extract the caller ARN from an STS GetCallerIdentity XML response."""
107
+ try:
108
+ root = ET.fromstring(xml_text)
109
+ # AWS STS response uses this namespace
110
+ ns = {"sts": "https://sts.amazonaws.com/doc/2011-06-15/"}
111
+ arn_el = root.find(".//sts:Arn", ns)
112
+ if arn_el is not None and arn_el.text:
113
+ return arn_el.text
114
+ # Fallback: no namespace (unit-test stubs)
115
+ arn_el = root.find(".//Arn")
116
+ if arn_el is not None and arn_el.text:
117
+ return arn_el.text
118
+ except ET.ParseError as exc:
119
+ raise ValueError("sts_parse_failed") from exc
120
+ raise ValueError("sts_parse_failed")