@biffo/cli 0.38.0 → 0.41.0
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/_skeletons/plugin-template/.github/workflows/ci.yml +124 -0
- package/_skeletons/plugin-template/.github/workflows/release.yml +114 -0
- package/_skeletons/plugin-template/README.md +232 -0
- package/_skeletons/plugin-template/biffo.plugin.json +89 -0
- package/_skeletons/plugin-template/pyproject.toml +100 -0
- package/_skeletons/plugin-template/registry-schema.json +294 -0
- package/_skeletons/plugin-template/src/__init__.py +0 -0
- package/_skeletons/plugin-template/src/example_plugin/__init__.py +10 -0
- package/_skeletons/plugin-template/src/example_plugin/main.py +63 -0
- package/_skeletons/plugin-template/src/example_plugin/manifest.py +30 -0
- package/_skeletons/plugin-template/src/example_plugin/plugin.py +92 -0
- package/_skeletons/plugin-template/terraform/README.md +146 -0
- package/_skeletons/plugin-template/terraform/main.tf +167 -0
- package/_skeletons/plugin-template/terraform/outputs.tf +33 -0
- package/_skeletons/plugin-template/terraform/variables.tf +133 -0
- package/_skeletons/plugin-template/tests/conftest.py +17 -0
- package/_skeletons/plugin-template/tests/fakes.py +74 -0
- package/_skeletons/plugin-template/tests/test_example_plugin.py +108 -0
- package/_skeletons/registry/README.md +27 -0
- package/_skeletons/registry/plugins.json +5 -0
- package/_skeletons/registry/registry-schema.json +294 -0
- package/_skeletons/sibling-template/.github/renovate.json +38 -0
- package/_skeletons/sibling-template/.github/workflows/ci.yml +185 -0
- package/_skeletons/sibling-template/.github/workflows/codeql.yml +56 -0
- package/_skeletons/sibling-template/.github/workflows/deploy.yml +219 -0
- package/_skeletons/sibling-template/.github/workflows/destroy-infra.yml +73 -0
- package/_skeletons/sibling-template/README.md +164 -0
- package/_skeletons/sibling-template/_gitignore +71 -0
- package/_skeletons/sibling-template/apps/frontend/.env.example +14 -0
- package/_skeletons/sibling-template/apps/frontend/eslint.config.mjs +10 -0
- package/_skeletons/sibling-template/apps/frontend/next.config.ts +20 -0
- package/_skeletons/sibling-template/apps/frontend/package.json +42 -0
- package/_skeletons/sibling-template/apps/frontend/pnpm-lock.yaml +5268 -0
- package/_skeletons/sibling-template/apps/frontend/pnpm-workspace.yaml +19 -0
- package/_skeletons/sibling-template/apps/frontend/src/app/globals.css +34 -0
- package/_skeletons/sibling-template/apps/frontend/src/app/layout.tsx +15 -0
- package/_skeletons/sibling-template/apps/frontend/src/app/page.test.tsx +75 -0
- package/_skeletons/sibling-template/apps/frontend/src/app/page.tsx +85 -0
- package/_skeletons/sibling-template/apps/frontend/src/lib/api-client.ts +58 -0
- package/_skeletons/sibling-template/apps/frontend/src/lib/auth.test.ts +135 -0
- package/_skeletons/sibling-template/apps/frontend/src/lib/auth.ts +87 -0
- package/_skeletons/sibling-template/apps/frontend/src/test-setup.ts +1 -0
- package/_skeletons/sibling-template/apps/frontend/tsconfig.json +29 -0
- package/_skeletons/sibling-template/apps/frontend/vitest.config.ts +18 -0
- package/_skeletons/sibling-template/biffo.sibling.json +7 -0
- package/_skeletons/sibling-template/infra/backend.tf +23 -0
- package/_skeletons/sibling-template/infra/main.tf +79 -0
- package/_skeletons/sibling-template/infra/outputs.tf +17 -0
- package/_skeletons/sibling-template/infra/variables.tf +63 -0
- package/_skeletons/sibling-template/modules/cloud/aws/api-gateway/main.tf +114 -0
- package/_skeletons/sibling-template/modules/cloud/aws/api-gateway/outputs.tf +12 -0
- package/_skeletons/sibling-template/modules/cloud/aws/api-gateway/variables.tf +41 -0
- package/_skeletons/sibling-template/modules/cloud/aws/compute/main.tf +157 -0
- package/_skeletons/sibling-template/modules/cloud/aws/compute/outputs.tf +4 -0
- package/_skeletons/sibling-template/modules/cloud/aws/compute/variables.tf +70 -0
- package/_skeletons/sibling-template/modules/cloud/aws/storage/main.tf +78 -0
- package/_skeletons/sibling-template/modules/cloud/aws/storage/outputs.tf +4 -0
- package/_skeletons/sibling-template/modules/cloud/aws/storage/variables.tf +12 -0
- package/_skeletons/sibling-template/services/api/pyproject.toml +69 -0
- package/_skeletons/sibling-template/services/api/src/api/__init__.py +0 -0
- package/_skeletons/sibling-template/services/api/src/api/config.py +31 -0
- package/_skeletons/sibling-template/services/api/src/api/core_client.py +64 -0
- package/_skeletons/sibling-template/services/api/src/api/main.py +43 -0
- package/_skeletons/sibling-template/services/api/src/api/middleware/__init__.py +0 -0
- package/_skeletons/sibling-template/services/api/src/api/middleware/auth.py +111 -0
- package/_skeletons/sibling-template/services/api/src/api/routers/__init__.py +0 -0
- package/_skeletons/sibling-template/services/api/src/api/routers/whoami.py +21 -0
- package/_skeletons/sibling-template/services/api/tests/conftest.py +24 -0
- package/_skeletons/sibling-template/services/api/tests/test_whoami.py +21 -0
- package/_skeletons/sibling-template/services/api/uv.lock +1160 -0
- package/core.version +1 -1
- package/dist/index.js +113 -87
- package/package.json +5 -4
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from fastapi import HTTPException, Security, status
|
|
3
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
4
|
+
|
|
5
|
+
from .config import settings
|
|
6
|
+
|
|
7
|
+
_security = HTTPBearer()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CoreApiError(Exception):
|
|
11
|
+
def __init__(self, status_code: int, detail: str) -> None:
|
|
12
|
+
self.status_code = status_code
|
|
13
|
+
self.detail = detail
|
|
14
|
+
super().__init__(detail)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CoreApiClient:
|
|
18
|
+
"""
|
|
19
|
+
Thin per-request client for calling the core project's API (ADR-0002/
|
|
20
|
+
ADR-0007) — this is the ONLY way this service may read or write data
|
|
21
|
+
that belongs to the core. It is deliberately NOT
|
|
22
|
+
packages/python-sdk's BiffoAPIClient: that client is built around a
|
|
23
|
+
single static BIFFO_JWT_TOKEN env var for background/event-driven
|
|
24
|
+
plugin code, not a live per-request user token. Here we forward the
|
|
25
|
+
caller's own bearer token, so the core API applies the exact same
|
|
26
|
+
tenant/permission scoping it would for a request made directly against
|
|
27
|
+
it — this service never gets elevated privileges the calling user
|
|
28
|
+
didn't already have.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, bearer_token: str) -> None:
|
|
32
|
+
self._bearer_token = bearer_token
|
|
33
|
+
|
|
34
|
+
async def get(self, path: str) -> dict:
|
|
35
|
+
async with httpx.AsyncClient(base_url=settings.core_api_url, timeout=10) as client:
|
|
36
|
+
response = await client.get(
|
|
37
|
+
path,
|
|
38
|
+
headers={"Authorization": f"Bearer {self._bearer_token}"},
|
|
39
|
+
)
|
|
40
|
+
if response.is_error:
|
|
41
|
+
raise CoreApiError(response.status_code, response.text)
|
|
42
|
+
return response.json() # type: ignore[no-any-return]
|
|
43
|
+
|
|
44
|
+
async def post(self, path: str, body: dict) -> dict:
|
|
45
|
+
async with httpx.AsyncClient(base_url=settings.core_api_url, timeout=10) as client:
|
|
46
|
+
response = await client.post(
|
|
47
|
+
path,
|
|
48
|
+
json=body,
|
|
49
|
+
headers={"Authorization": f"Bearer {self._bearer_token}"},
|
|
50
|
+
)
|
|
51
|
+
if response.is_error:
|
|
52
|
+
raise CoreApiError(response.status_code, response.text)
|
|
53
|
+
return response.json() # type: ignore[no-any-return]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_core_client(
|
|
57
|
+
credentials: HTTPAuthorizationCredentials = Security(_security),
|
|
58
|
+
) -> CoreApiClient:
|
|
59
|
+
if not settings.core_api_url:
|
|
60
|
+
raise HTTPException(
|
|
61
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
62
|
+
detail="core_api_url is not configured",
|
|
63
|
+
)
|
|
64
|
+
return CoreApiClient(credentials.credentials)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from aws_lambda_powertools import Logger, Tracer
|
|
4
|
+
from aws_lambda_powertools.utilities.typing import LambdaContext
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
7
|
+
from mangum import Mangum
|
|
8
|
+
|
|
9
|
+
from .config import settings
|
|
10
|
+
from .routers import whoami
|
|
11
|
+
|
|
12
|
+
logger = Logger()
|
|
13
|
+
tracer = Tracer()
|
|
14
|
+
|
|
15
|
+
app = FastAPI(
|
|
16
|
+
title="Sibling API",
|
|
17
|
+
version="0.0.0",
|
|
18
|
+
docs_url="/api/docs" if settings.environment != "prod" else None,
|
|
19
|
+
redoc_url=None,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
app.add_middleware(
|
|
23
|
+
CORSMiddleware,
|
|
24
|
+
allow_origins=settings.cors_origins,
|
|
25
|
+
allow_credentials=True,
|
|
26
|
+
allow_methods=["*"],
|
|
27
|
+
allow_headers=["*"],
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
app.include_router(whoami.router, prefix="/api/v1")
|
|
31
|
+
|
|
32
|
+
handler = Mangum(app, lifespan="off")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@logger.inject_lambda_context
|
|
36
|
+
@tracer.capture_lambda_handler
|
|
37
|
+
def lambda_handler(event: dict, context: LambdaContext) -> dict:
|
|
38
|
+
# asyncio.run() (used internally by httpx's async client teardown, among
|
|
39
|
+
# other things) sets the current event loop to None when it exits,
|
|
40
|
+
# causing asyncio.get_event_loop() (used by Mangum) to raise RuntimeError
|
|
41
|
+
# in Python 3.12+. Recreate the loop before each invocation.
|
|
42
|
+
asyncio.set_event_loop(asyncio.new_event_loop())
|
|
43
|
+
return handler(event, context) # type: ignore[reportArgumentType]
|
|
File without changes
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import jwt
|
|
8
|
+
from aws_lambda_powertools import Logger
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
|
10
|
+
from fastapi import HTTPException, Security, status
|
|
11
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
12
|
+
from jwt import PyJWTError
|
|
13
|
+
from jwt.algorithms import RSAAlgorithm
|
|
14
|
+
|
|
15
|
+
from ..config import settings
|
|
16
|
+
|
|
17
|
+
logger = Logger()
|
|
18
|
+
_security = HTTPBearer()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class AuthenticatedUser:
|
|
23
|
+
"""Verified identity extracted from the core project's Cognito JWT."""
|
|
24
|
+
|
|
25
|
+
sub: str
|
|
26
|
+
email: str
|
|
27
|
+
username: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Ported near-verbatim from the core project's services/api/src/api/middleware/auth.py
|
|
31
|
+
# (ADR-0007) — this sibling's API Gateway already has its own Cognito JWT
|
|
32
|
+
# authorizer (see modules/cloud/aws/api-gateway), so a request only reaches
|
|
33
|
+
# this Lambda at all if a valid token was already presented. This second,
|
|
34
|
+
# independent verification is deliberate defense in depth, not redundant:
|
|
35
|
+
# it means the Lambda's own authorization logic never trusts API Gateway's
|
|
36
|
+
# authorizer having run correctly, matching how the core API itself behaves.
|
|
37
|
+
@lru_cache(maxsize=1)
|
|
38
|
+
def _get_jwks(user_pool_id: str, region: str) -> dict:
|
|
39
|
+
"""Fetch and cache JWKS. Cached per Lambda instance lifetime."""
|
|
40
|
+
if settings.cognito_jwks_json:
|
|
41
|
+
return json.loads(settings.cognito_jwks_json) # type: ignore[no-any-return]
|
|
42
|
+
url = f"https://cognito-idp.{region}.amazonaws.com/{user_pool_id}/.well-known/jwks.json"
|
|
43
|
+
response = httpx.get(url, timeout=10)
|
|
44
|
+
response.raise_for_status()
|
|
45
|
+
return response.json() # type: ignore[no-any-return]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _verify_token(token: str) -> dict:
|
|
49
|
+
try:
|
|
50
|
+
unverified_headers = jwt.get_unverified_header(token)
|
|
51
|
+
except PyJWTError as exc:
|
|
52
|
+
raise HTTPException(
|
|
53
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
54
|
+
detail="Malformed token",
|
|
55
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
56
|
+
) from exc
|
|
57
|
+
|
|
58
|
+
kid = unverified_headers.get("kid")
|
|
59
|
+
jwks = _get_jwks(settings.cognito_user_pool_id, settings.cognito_region)
|
|
60
|
+
signing_key = next((k for k in jwks["keys"] if k["kid"] == kid), None)
|
|
61
|
+
|
|
62
|
+
if signing_key is None and not settings.cognito_jwks_json:
|
|
63
|
+
# Unknown kid and we can fetch remotely — JWKS may have rotated;
|
|
64
|
+
# bust the cache and retry once.
|
|
65
|
+
_get_jwks.cache_clear()
|
|
66
|
+
jwks = _get_jwks(settings.cognito_user_pool_id, settings.cognito_region)
|
|
67
|
+
signing_key = next((k for k in jwks["keys"] if k["kid"] == kid), None)
|
|
68
|
+
|
|
69
|
+
if signing_key is None:
|
|
70
|
+
raise HTTPException(
|
|
71
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
72
|
+
detail="Unknown signing key",
|
|
73
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
# PyJWT needs a key object, not a raw JWK dict — convert the matched JWK.
|
|
78
|
+
# A Cognito JWKS only publishes public keys, so this is always public.
|
|
79
|
+
public_key = cast(RSAPublicKey, RSAAlgorithm.from_jwk(json.dumps(signing_key)))
|
|
80
|
+
claims: dict = jwt.decode(
|
|
81
|
+
token,
|
|
82
|
+
public_key,
|
|
83
|
+
algorithms=["RS256"],
|
|
84
|
+
audience=settings.cognito_client_id,
|
|
85
|
+
)
|
|
86
|
+
except PyJWTError as exc:
|
|
87
|
+
raise HTTPException(
|
|
88
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
89
|
+
detail=f"Token invalid: {exc}",
|
|
90
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
91
|
+
) from exc
|
|
92
|
+
|
|
93
|
+
return claims
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def require_auth(
|
|
97
|
+
credentials: HTTPAuthorizationCredentials = Security(_security),
|
|
98
|
+
) -> AuthenticatedUser:
|
|
99
|
+
"""
|
|
100
|
+
FastAPI dependency that verifies the core project's Cognito JWT and
|
|
101
|
+
returns the caller's identity.
|
|
102
|
+
|
|
103
|
+
Raises HTTP 401 if the token is missing, expired, or invalid.
|
|
104
|
+
"""
|
|
105
|
+
claims = _verify_token(credentials.credentials)
|
|
106
|
+
|
|
107
|
+
return AuthenticatedUser(
|
|
108
|
+
sub=claims["sub"],
|
|
109
|
+
email=claims.get("email", ""),
|
|
110
|
+
username=claims.get("cognito:username", claims.get("username", "")),
|
|
111
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends
|
|
2
|
+
|
|
3
|
+
from ..middleware.auth import AuthenticatedUser, require_auth
|
|
4
|
+
|
|
5
|
+
router = APIRouter()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@router.get("/whoami")
|
|
9
|
+
async def whoami(caller: AuthenticatedUser = Depends(require_auth)) -> dict:
|
|
10
|
+
"""
|
|
11
|
+
Proves the shared-Cognito-session SSO (ADR-0007) actually works: the
|
|
12
|
+
frontend calls this immediately after finding a session, and only
|
|
13
|
+
renders "Hello <username>" once this independently-verified response
|
|
14
|
+
comes back — never trusting a client-side JWT decode for display.
|
|
15
|
+
"""
|
|
16
|
+
return {"username": caller.username or caller.email or caller.sub}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.get("/health")
|
|
20
|
+
async def health() -> dict:
|
|
21
|
+
return {"status": "ok"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from collections.abc import Generator
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from fastapi.testclient import TestClient
|
|
5
|
+
|
|
6
|
+
from api.main import app
|
|
7
|
+
from api.middleware.auth import AuthenticatedUser, require_auth
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def authenticated_client() -> Generator[TestClient]:
|
|
12
|
+
app.dependency_overrides[require_auth] = lambda: AuthenticatedUser(
|
|
13
|
+
sub="abc-123",
|
|
14
|
+
email="a@example.com",
|
|
15
|
+
username="testuser",
|
|
16
|
+
)
|
|
17
|
+
client = TestClient(app)
|
|
18
|
+
yield client
|
|
19
|
+
app.dependency_overrides.pop(require_auth, None)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def client() -> TestClient:
|
|
24
|
+
return TestClient(app)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from fastapi.testclient import TestClient
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_whoami_returns_username_for_authenticated_caller(authenticated_client: TestClient) -> None:
|
|
5
|
+
response = authenticated_client.get("/api/v1/whoami")
|
|
6
|
+
|
|
7
|
+
assert response.status_code == 200
|
|
8
|
+
assert response.json() == {"username": "testuser"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_whoami_rejects_unauthenticated_requests(client: TestClient) -> None:
|
|
12
|
+
response = client.get("/api/v1/whoami")
|
|
13
|
+
|
|
14
|
+
assert response.status_code == 401 # No Authorization header at all — HTTPBearer's own 401
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_health_requires_no_auth(client: TestClient) -> None:
|
|
18
|
+
response = client.get("/api/v1/health")
|
|
19
|
+
|
|
20
|
+
assert response.status_code == 200
|
|
21
|
+
assert response.json() == {"status": "ok"}
|