@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.
- package/notes-api/app/config.py +48 -3
- package/notes-api/app/routers/auth.py +180 -2
- package/notes-api/app/routers/issues.py +45 -0
- package/notes-api/app/schemas/auth.py +33 -1
- package/notes-api/app/services/github_issues.py +24 -0
- package/notes-api/app/services/iam_auth.py +120 -0
- package/notes-api/tests/test_iam_auth.py +403 -0
- package/notes-app/README.md +14 -0
- package/notes-app/src/components/CommentPanel.tsx +325 -0
- package/notes-app/src/components/EditToolbar.tsx +173 -0
- package/notes-app/src/components/FileInfoPanel.tsx +382 -0
- package/notes-app/src/components/IssueDetailView.test.tsx +201 -0
- package/notes-app/src/components/IssueDetailView.tsx +19 -2
- package/notes-app/src/components/IssueList.test.tsx +93 -0
- package/notes-app/src/components/IssueList.tsx +4 -3
- package/notes-app/src/components/LabelSelector.test.tsx +114 -0
- package/notes-app/src/components/LabelSelector.tsx +276 -0
- package/notes-app/src/components/SpreadsheetEditor.test.tsx +285 -0
- package/notes-app/src/components/SpreadsheetEditor.tsx +40 -0
- package/notes-app/src/components/TreeNode.tsx +9 -1
- package/notes-app/src/hooks/useComments.ts +230 -0
- package/notes-app/src/lib/api.ts +125 -0
- package/notes-app/src/pages/MainPage.tsx +106 -13
- package/package.json +1 -1
- package/templates/agents/user-clone.hbs +6 -0
package/notes-api/app/config.py
CHANGED
|
@@ -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
|
|
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")
|