Compare commits
4 Commits
74ad4950b2
...
5b16114b98
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b16114b98 | |||
| 18ea9a4686 | |||
| 32e8ac8474 | |||
| 8e9fa3116c |
@@ -27,6 +27,7 @@ from fastapi import Header, Request
|
|||||||
from api.app.config import Settings
|
from api.app.config import Settings
|
||||||
from api.app.ept import EptInvalid, looks_like_ept, verify_ept
|
from api.app.ept import EptInvalid, looks_like_ept, verify_ept
|
||||||
from api.app.errors import RepairPointer, passport_unresolvable
|
from api.app.errors import RepairPointer, passport_unresolvable
|
||||||
|
from api.app.hub_jwt import HubTokenInvalid, verify_hub_token
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -239,22 +240,29 @@ async def get_caller(
|
|||||||
allowed_actions=actions,
|
allowed_actions=actions,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- human (account-server RS256) -------------------------------------
|
# --- human (hub RS256 access token, G3.2) ------------------------------
|
||||||
if settings.is_production and settings.require_verified_jwt:
|
# Production ALWAYS verifies, whatever require_verified_jwt says: the flag
|
||||||
# I-8, applied to ourselves. G3.2's JWKS verifier is not written yet, and
|
# only exists to let local dev run against unsigned fixture tokens.
|
||||||
# an unverified JWT is an authentication bypass rather than a shortcut.
|
if settings.require_verified_jwt or settings.is_production:
|
||||||
# Refusing is the only honest answer until the verifier exists.
|
try:
|
||||||
raise RepairPointer(
|
human = verify_hub_token(
|
||||||
status_code=503,
|
token,
|
||||||
code="human_signin_not_ready",
|
settings.account_server_base_url,
|
||||||
speak="Signing in isn't switched on yet. Nothing you have is affected.",
|
issuers=tuple(settings.hub_issuers),
|
||||||
machine_cause=(
|
audiences=tuple(settings.hub_audiences),
|
||||||
"JWKS verification (G3.2) is not implemented; refusing to accept "
|
require_aud=settings.hub_require_aud,
|
||||||
"an unverified human token in production"
|
|
||||||
),
|
|
||||||
remediation_tool=None,
|
|
||||||
)
|
)
|
||||||
|
except HubTokenInvalid as exc:
|
||||||
|
raise RepairPointer(
|
||||||
|
status_code=401,
|
||||||
|
code="token_invalid",
|
||||||
|
speak="We couldn't confirm that sign-in. Try signing in again.",
|
||||||
|
machine_cause=f"hub token verification failed: {exc}",
|
||||||
|
remediation_tool=None,
|
||||||
|
) from exc
|
||||||
|
return Caller(actor_type=ActorType.human, identity_id=human.identity_id)
|
||||||
|
|
||||||
|
# Local dev only (require_verified_jwt=False outside production).
|
||||||
identity_id = _unverified_claim(token, "windy_identity_id") or _unverified_claim(token, "sub")
|
identity_id = _unverified_claim(token, "windy_identity_id") or _unverified_claim(token, "sub")
|
||||||
if not identity_id:
|
if not identity_id:
|
||||||
raise RepairPointer(
|
raise RepairPointer(
|
||||||
@@ -274,10 +282,8 @@ def _unverified_claim(token: str, claim: str) -> str | None:
|
|||||||
on the result re-establishes trust independently: an agent's authority comes
|
on the result re-establishes trust independently: an agent's authority comes
|
||||||
from a live Eternitas trust lookup, never from the token's own assertions.
|
from a live Eternitas trust lookup, never from the token's own assertions.
|
||||||
|
|
||||||
⚠️ Full RS256/ES256 JWKS verification for the human path lands in G3.2's
|
Humans are verified by hub_jwt.verify_hub_token (G3.2); this reader backs
|
||||||
verifier and MUST be in place before `api.windygit.com` accepts a human
|
only the local-dev path, which production never takes.
|
||||||
token from outside. Until then the human path is reachable only from inside
|
|
||||||
the tunnel, and `settings.require_verified_jwt` refuses it in production.
|
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
|||||||
@@ -53,16 +53,27 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# ---- account-server OIDC (human identity) -----------------------------
|
# ---- account-server OIDC (human identity) -----------------------------
|
||||||
account_server_base_url: str = "https://account.windyword.ai"
|
account_server_base_url: str = "https://account.windyword.ai"
|
||||||
|
# G3.2 — what a hub ACCESS token must say about itself (see hub_jwt.py).
|
||||||
|
# Token contract v1 (lane 8c, 2026-09-23): access tokens may carry either
|
||||||
|
# issuer. id_tokens are kept out by `type` + `windy_identity_id` + aud, not
|
||||||
|
# by issuer.
|
||||||
|
hub_issuers: list[str] = ["windy-identity", "https://account.windyword.ai"]
|
||||||
|
# Contract v1: aud is an ARRAY; first-party tokens list every product, and
|
||||||
|
# Windy Git's entry is `windy_git` (underscore). ⚠️ NEVER add "windy-git"
|
||||||
|
# (hyphen): that is Gitea's OIDC client_id, so an id_token minted for the
|
||||||
|
# forge would carry it and pass as a bearer here.
|
||||||
|
hub_audiences: list[str] = ["windy_git"]
|
||||||
|
# Flip to True once the hub emits aud on every access token.
|
||||||
|
hub_require_aud: bool = False
|
||||||
|
|
||||||
# Internal callers (the Cloud portal calling /internal/*). A first-class
|
# Internal callers (the Cloud portal calling /internal/*). A first-class
|
||||||
# caller class, not a bypass: unset means service calls are REFUSED.
|
# caller class, not a bypass: unset means service calls are REFUSED.
|
||||||
service_token: str = ""
|
service_token: str = ""
|
||||||
|
|
||||||
# ⚠️ FAIL-CLOSED GATE. Full RS256/ES256 JWKS verification lands in G3.2.
|
# ⚠️ FAIL-CLOSED GATE. Human tokens are verified against the hub's JWKS
|
||||||
# Until it does, the human token path must not be reachable in production —
|
# (G3.2, hub_jwt.py). False only enables the unverified local-dev path, and
|
||||||
# accepting an unverified JWT is not a shortcut, it is an authentication
|
# production verifies regardless — an unverified JWT is a bypass, not a
|
||||||
# bypass. Agents are unaffected: their authority comes from a live Eternitas
|
# shortcut.
|
||||||
# trust lookup, not from anything the token asserts about itself.
|
|
||||||
require_verified_jwt: bool = True
|
require_verified_jwt: bool = True
|
||||||
|
|
||||||
# ---- storage law (I-3, G4.4) ------------------------------------------
|
# ---- storage law (I-3, G4.4) ------------------------------------------
|
||||||
|
|||||||
133
api/app/hub_jwt.py
Normal file
133
api/app/hub_jwt.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""Human token verification (G3.2) — hub access tokens from account.windyword.ai.
|
||||||
|
|
||||||
|
Until this existed the human path refused every token in production (503
|
||||||
|
`human_signin_not_ready`), because reading an unverified JWT's claims is an
|
||||||
|
authentication bypass, not a shortcut. This module is what lets it say yes.
|
||||||
|
|
||||||
|
The token it accepts is the hub's ACCESS token, as observed live 2026-09-23:
|
||||||
|
|
||||||
|
header {alg: RS256, typ: JWT, kid: <published at /.well-known/jwks.json>}
|
||||||
|
claims iss = "windy-identity" (contract v1 also allows the discovery URL)
|
||||||
|
type = "human", exp - iat = 900 s
|
||||||
|
sub = per-row user id ← NOT the cross-product identity
|
||||||
|
windy_identity_id = the Windy Account UUID (what Gitea's OIDC links on)
|
||||||
|
no `aud` yet
|
||||||
|
|
||||||
|
What it refuses, by construction:
|
||||||
|
|
||||||
|
* **Anything but RS256.** One algorithm, never a list. Closes `alg: none` and
|
||||||
|
HS256-with-the-public-key confusion.
|
||||||
|
* **An unknown `kid`**, a wrong issuer, an expired token — library-checked.
|
||||||
|
* **An id_token used as a bearer.** id_tokens prove a login happened to a
|
||||||
|
relying party (for the forge: aud `windy-git`), not that this caller may act
|
||||||
|
here. They carry no `type` and no `windy_identity_id`, and their aud is a
|
||||||
|
client id, not the product name `windy_git` — any one of the three refuses.
|
||||||
|
* **A non-human `type`.** An agent's authority comes from its EPT and a live
|
||||||
|
Eternitas lookup, never from a hub token dressed as a person.
|
||||||
|
* **A token with no `windy_identity_id`.** `sub` is a different namespace (the
|
||||||
|
per-row user id); falling back to it would silently mint identities that
|
||||||
|
match nothing Gitea knows.
|
||||||
|
|
||||||
|
`aud` (token contract v1, lane 8c): an array; first-party tokens list every
|
||||||
|
product and Windy Git's is `windy_git`. Optional until the hub emits it; when
|
||||||
|
present it MUST include `windy_git`.
|
||||||
|
`hub_require_aud=True` makes it mandatory — flip it once the hub emits it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from jwt import PyJWKClient
|
||||||
|
|
||||||
|
ALGORITHMS = ["RS256"] # exactly one. Never widen this list.
|
||||||
|
|
||||||
|
_jwks_client: PyJWKClient | None = None
|
||||||
|
_jwks_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HubTokenInvalid(Exception):
|
||||||
|
"""Not a valid, currently-signed hub access token for a human."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VerifiedHuman:
|
||||||
|
identity_id: str
|
||||||
|
email: str | None
|
||||||
|
expires_at: int | None
|
||||||
|
|
||||||
|
|
||||||
|
def _client(base_url: str) -> PyJWKClient:
|
||||||
|
"""Cached JWKS client; refetches on an unknown kid so rotation self-heals."""
|
||||||
|
global _jwks_client, _jwks_url
|
||||||
|
url = f"{base_url.rstrip('/')}/.well-known/jwks.json"
|
||||||
|
if _jwks_client is None or _jwks_url != url:
|
||||||
|
_jwks_client = PyJWKClient(url, cache_keys=True, lifespan=300)
|
||||||
|
_jwks_url = url
|
||||||
|
return _jwks_client
|
||||||
|
|
||||||
|
|
||||||
|
def verify_hub_token(
|
||||||
|
token: str,
|
||||||
|
base_url: str,
|
||||||
|
*,
|
||||||
|
issuers: tuple[str, ...],
|
||||||
|
audiences: tuple[str, ...],
|
||||||
|
require_aud: bool,
|
||||||
|
signing_key=None,
|
||||||
|
) -> VerifiedHuman:
|
||||||
|
"""Verify a hub access token. Raises HubTokenInvalid on ANY doubt.
|
||||||
|
|
||||||
|
`signing_key` exists for tests only (a locally generated key, no network).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
key = (
|
||||||
|
signing_key
|
||||||
|
if signing_key is not None
|
||||||
|
else _client(base_url).get_signing_key_from_jwt(token).key
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - unknown kid, unreachable JWKS, malformed
|
||||||
|
raise HubTokenInvalid(f"no usable signing key: {type(exc).__name__}: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
claims = jwt.decode(
|
||||||
|
token,
|
||||||
|
key,
|
||||||
|
algorithms=ALGORITHMS,
|
||||||
|
issuer=list(issuers),
|
||||||
|
options={
|
||||||
|
"require": ["iss", "exp", "iat"],
|
||||||
|
"verify_signature": True,
|
||||||
|
"verify_exp": True,
|
||||||
|
"verify_iss": True,
|
||||||
|
# Checked by hand below: PyJWT rejects any token CARRYING aud
|
||||||
|
# when no audience is passed, which would break the moment the
|
||||||
|
# hub starts emitting it — the exact trap the SSO matrix names.
|
||||||
|
"verify_aud": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except jwt.PyJWTError as exc:
|
||||||
|
raise HubTokenInvalid(f"{type(exc).__name__}: {exc}") from exc
|
||||||
|
|
||||||
|
aud = claims.get("aud")
|
||||||
|
if aud is None:
|
||||||
|
if require_aud:
|
||||||
|
raise HubTokenInvalid("token carries no aud and hub_require_aud is on")
|
||||||
|
else:
|
||||||
|
presented = {aud} if isinstance(aud, str) else set(aud) if isinstance(aud, list) else set()
|
||||||
|
if not presented & set(audiences):
|
||||||
|
raise HubTokenInvalid(f"aud {sorted(presented)} does not name Windy Git")
|
||||||
|
|
||||||
|
# REQUIRED, not defaulted: id_tokens carry no `type`, and this is one of the
|
||||||
|
# two claims (with windy_identity_id) that keep them from acting as bearers.
|
||||||
|
if claims.get("type") != "human":
|
||||||
|
raise HubTokenInvalid(f"token type {claims.get('type')!r} is not a human access token")
|
||||||
|
|
||||||
|
identity = claims.get("windy_identity_id") or claims.get("windyIdentityId")
|
||||||
|
if not isinstance(identity, str) or not identity.strip():
|
||||||
|
raise HubTokenInvalid("token carries no windy_identity_id")
|
||||||
|
|
||||||
|
return VerifiedHuman(
|
||||||
|
identity_id=identity, email=claims.get("email"), expires_at=claims.get("exp")
|
||||||
|
)
|
||||||
169
api/tests/test_hub_jwt.py
Normal file
169
api/tests/test_hub_jwt.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""G3.2 / I-8 — human tokens are verified, never read (SSO #14, 2026-09-23).
|
||||||
|
|
||||||
|
Behavioral: every case signs a real RS256 token with a locally generated key
|
||||||
|
and drives `get_caller`, so a green run means the gate refuses what it must —
|
||||||
|
not that some string appears in auth.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
|
||||||
|
from api.app import hub_jwt
|
||||||
|
from api.app.config import Settings
|
||||||
|
from api.app.errors import RepairPointer
|
||||||
|
|
||||||
|
KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
OTHER = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
IDENTITY = "5e1b9569-7f01-489d-bf14-6fe5a367fa3f"
|
||||||
|
|
||||||
|
|
||||||
|
def _claims(**over):
|
||||||
|
now = int(time.time())
|
||||||
|
c = {
|
||||||
|
"iss": "windy-identity",
|
||||||
|
"type": "human",
|
||||||
|
"sub": "row-id-not-identity",
|
||||||
|
"windy_identity_id": IDENTITY,
|
||||||
|
"email": "grant@example.com",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + 900,
|
||||||
|
}
|
||||||
|
c.update(over)
|
||||||
|
return {k: v for k, v in c.items() if v is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(claims, key=KEY, alg="RS256"):
|
||||||
|
return jwt.encode(claims, key, algorithm=alg, headers={"kid": "test"})
|
||||||
|
|
||||||
|
|
||||||
|
class _Req:
|
||||||
|
def __init__(self, settings):
|
||||||
|
self.app = type("A", (), {"state": type("S", (), {"settings": settings})()})()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _local_jwks(monkeypatch):
|
||||||
|
"""The hub's JWKS, served from KEY's public half — no network."""
|
||||||
|
|
||||||
|
class _Key:
|
||||||
|
key = KEY.public_key()
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
def get_signing_key_from_jwt(self, token):
|
||||||
|
return _Key()
|
||||||
|
|
||||||
|
monkeypatch.setattr(hub_jwt, "_client", lambda base_url: _Client())
|
||||||
|
|
||||||
|
|
||||||
|
async def _caller(token, **settings):
|
||||||
|
from api.app.auth import get_caller
|
||||||
|
|
||||||
|
s = Settings(environment="production", **settings)
|
||||||
|
return await get_caller(_Req(s), authorization=f"Bearer {token}", x_service_token=None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _refused(token, **settings):
|
||||||
|
with pytest.raises(RepairPointer) as exc:
|
||||||
|
await _caller(token, **settings)
|
||||||
|
assert exc.value.status_code == 401 and exc.value.code == "token_invalid"
|
||||||
|
return exc.value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_genuine_hub_token_is_a_human_named_by_windy_identity_id():
|
||||||
|
c = await _caller(_sign(_claims()))
|
||||||
|
assert c.actor_type == "human"
|
||||||
|
assert c.identity_id == IDENTITY # NOT `sub`, which is the per-row user id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forged_signature_is_refused():
|
||||||
|
await _refused(_sign(_claims(), key=OTHER))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_expired_token_is_refused():
|
||||||
|
await _refused(_sign(_claims(iat=int(time.time()) - 2000, exp=int(time.time()) - 60)))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wrong_issuer_and_id_tokens_are_refused():
|
||||||
|
await _refused(_sign(_claims(iss="https://evil.example")))
|
||||||
|
# Contract v1: the discovery-URL issuer is legal for ACCESS tokens...
|
||||||
|
c = await _caller(_sign(_claims(iss="https://account.windyword.ai")))
|
||||||
|
assert c.identity_id == IDENTITY
|
||||||
|
# ...but an id_token minted for the forge (aud = Gitea's client id
|
||||||
|
# "windy-git", no type, sub = identity) must never act as a bearer here.
|
||||||
|
id_token = _claims(
|
||||||
|
iss="https://account.windyword.ai",
|
||||||
|
aud="windy-git",
|
||||||
|
type=None,
|
||||||
|
windy_identity_id=None,
|
||||||
|
sub=IDENTITY,
|
||||||
|
)
|
||||||
|
await _refused(_sign(id_token))
|
||||||
|
await _refused(_sign(dict(id_token, windy_identity_id=IDENTITY, type="human")))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hs256_confusion_is_refused():
|
||||||
|
# The classic forgery: HMAC the token with the PUBLIC key as the secret.
|
||||||
|
pub = KEY.public_key().public_bytes(
|
||||||
|
serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo
|
||||||
|
)
|
||||||
|
header = base64.urlsafe_b64encode(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()).rstrip(
|
||||||
|
b"="
|
||||||
|
)
|
||||||
|
body = base64.urlsafe_b64encode(json.dumps(_claims()).encode()).rstrip(b"=")
|
||||||
|
sig = base64.urlsafe_b64encode(
|
||||||
|
hmac.new(pub, header + b"." + body, hashlib.sha256).digest()
|
||||||
|
).rstrip(b"=")
|
||||||
|
await _refused((header + b"." + body + b"." + sig).decode())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_non_human_or_missing_type_is_refused():
|
||||||
|
await _refused(_sign(_claims(type="agent")))
|
||||||
|
await _refused(_sign(_claims(type=None)))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_windy_identity_is_refused_not_read_from_sub():
|
||||||
|
await _refused(_sign(_claims(windy_identity_id=None)))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_aud_is_tolerated_when_it_names_windy_git_and_refused_otherwise():
|
||||||
|
"""PyJWT rejects ANY aud-bearing token when no audience is configured — the
|
||||||
|
trap that would break the day the hub starts emitting aud."""
|
||||||
|
c = await _caller(_sign(_claims(aud=["windy_chat", "windy_git", "windy_mail"])))
|
||||||
|
assert c.identity_id == IDENTITY
|
||||||
|
await _refused(_sign(_claims(aud=["windy_chat"])))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_require_aud_refuses_tokens_without_it():
|
||||||
|
await _refused(_sign(_claims()), hub_require_aud=True)
|
||||||
|
c = await _caller(_sign(_claims(aud=["windy_git"])), hub_require_aud=True)
|
||||||
|
assert c.identity_id == IDENTITY
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_production_verifies_even_if_the_flag_is_off():
|
||||||
|
"""require_verified_jwt=False is a local-dev convenience; production must
|
||||||
|
never take the unverified path."""
|
||||||
|
await _refused(_sign(_claims(), key=OTHER), require_verified_jwt=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_algorithm_list_is_exactly_rs256():
|
||||||
|
assert hub_jwt.ALGORITHMS == ["RS256"]
|
||||||
@@ -308,12 +308,13 @@ def test_g36_trust_client_never_soft_allows():
|
|||||||
|
|
||||||
def test_g36_unverified_human_jwt_is_refused_in_production():
|
def test_g36_unverified_human_jwt_is_refused_in_production():
|
||||||
"""I-8 applied to ourselves: an unverified JWT is an authentication bypass,
|
"""I-8 applied to ourselves: an unverified JWT is an authentication bypass,
|
||||||
not a shortcut. Until G3.2's JWKS verifier exists, production refuses."""
|
not a shortcut. G3.2's verifier now exists; behavioral proof that forged,
|
||||||
|
expired, mis-issued and mis-audienced tokens are refused lives in
|
||||||
|
test_hub_jwt.py. Here: the gate defaults closed."""
|
||||||
from api.app.config import Settings
|
from api.app.config import Settings
|
||||||
|
|
||||||
assert Settings().require_verified_jwt is True
|
assert Settings().require_verified_jwt is True
|
||||||
src = (ROOT / "api" / "app" / "auth.py").read_text()
|
assert "windy-git" not in Settings().hub_audiences # Gitea's client_id: id_token confusion
|
||||||
assert "human_signin_not_ready" in src
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_auth_bypass_env_var_anywhere():
|
def test_no_auth_bypass_env_var_anywhere():
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ log:
|
|||||||
|
|
||||||
runner:
|
runner:
|
||||||
file: /data/.runner
|
file: /data/.runner
|
||||||
capacity: 1 # per runner; parallelism = number of runner services (4). See docker-compose.yml
|
capacity: 1 # per runner; parallelism = number of runner services (6). See docker-compose.yml
|
||||||
timeout: 30m
|
timeout: 90m # hard ceiling per job. eternitas's serial pytest is ~50 min; keep
|
||||||
|
# timeout-minutes in each workflow — a hang still reads as a hang
|
||||||
shutdown_timeout: 3m
|
shutdown_timeout: 3m
|
||||||
insecure: false
|
insecure: false
|
||||||
fetch_timeout: 5s
|
fetch_timeout: 5s
|
||||||
|
|||||||
@@ -134,6 +134,23 @@ services:
|
|||||||
<<: *env2
|
<<: *env2
|
||||||
GITEA_RUNNER_NAME: veron-1-4
|
GITEA_RUNNER_NAME: veron-1-4
|
||||||
volumes: [./config.yaml:/config.yaml:ro, runner-data-4:/data]
|
volumes: [./config.yaml:/config.yaml:ro, runner-data-4:/data]
|
||||||
|
# 5 and 6 added the same day: with ~11 private repos onboarded (windy-chat
|
||||||
|
# alone queues ~24 jobs per push) four runners left 50+ jobs waiting. The
|
||||||
|
# CPU ceiling is dind's (12 of 24 cores, G1.5), not the runner count, so more
|
||||||
|
# runners add concurrency for I/O-bound jobs (npm ci, uv sync) without
|
||||||
|
# taking more of Grant's workstation.
|
||||||
|
runner-5:
|
||||||
|
<<: *runner
|
||||||
|
environment:
|
||||||
|
<<: *env2
|
||||||
|
GITEA_RUNNER_NAME: veron-1-5
|
||||||
|
volumes: [./config.yaml:/config.yaml:ro, runner-data-5:/data]
|
||||||
|
runner-6:
|
||||||
|
<<: *runner
|
||||||
|
environment:
|
||||||
|
<<: *env2
|
||||||
|
GITEA_RUNNER_NAME: veron-1-6
|
||||||
|
volumes: [./config.yaml:/config.yaml:ro, runner-data-6:/data]
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
jobs:
|
jobs:
|
||||||
@@ -146,4 +163,6 @@ volumes:
|
|||||||
runner-data-2:
|
runner-data-2:
|
||||||
runner-data-3:
|
runner-data-3:
|
||||||
runner-data-4:
|
runner-data-4:
|
||||||
|
runner-data-5:
|
||||||
|
runner-data-6:
|
||||||
|
|
||||||
|
|||||||
@@ -57,12 +57,26 @@ services:
|
|||||||
# G2.2 — OIDC only. No local password login, no self-registration.
|
# G2.2 — OIDC only. No local password login, no self-registration.
|
||||||
GITEA__service__DISABLE_REGISTRATION: "true"
|
GITEA__service__DISABLE_REGISTRATION: "true"
|
||||||
GITEA__service__ALLOW_ONLY_EXTERNAL_REGISTRATION: "true"
|
GITEA__service__ALLOW_ONLY_EXTERNAL_REGISTRATION: "true"
|
||||||
|
# SSO #8 (2026-09-23): the password and passkey forms are OFF. windyadmin
|
||||||
|
# is site admin with a local password; leaving the form up made that
|
||||||
|
# password a second, phishable way into the whole forge. Break-glass is
|
||||||
|
# the CLI on Veron (`docker exec -u git windy-git-gitea-1 gitea admin ...`).
|
||||||
|
GITEA__service__ENABLE_PASSWORD_SIGNIN_FORM: "false"
|
||||||
|
GITEA__service__ENABLE_PASSKEY_AUTHENTICATION: "false"
|
||||||
# G3.1 — a Windy account IS the account. Signing in with Windy provisions
|
# G3.1 — a Windy account IS the account. Signing in with Windy provisions
|
||||||
# the Gitea user on first arrival; nobody is asked to invent a second
|
# the Gitea user on first arrival; nobody is asked to invent a second
|
||||||
# identity for the same person, and no local password ever exists.
|
# identity for the same person, and no local password ever exists.
|
||||||
GITEA__oauth2_client__ENABLE_AUTO_REGISTRATION: "true"
|
GITEA__oauth2_client__ENABLE_AUTO_REGISTRATION: "true"
|
||||||
GITEA__oauth2_client__USERNAME: email
|
GITEA__oauth2_client__USERNAME: email
|
||||||
GITEA__oauth2_client__ACCOUNT_LINKING: auto
|
# 🔴 `login`, NOT `auto` (SSO #8). `auto` linked any hub login whose EMAIL
|
||||||
|
# matched an existing account — and windyadmin (SITE ADMIN) carries Grant's
|
||||||
|
# email, so the forge's admin rights rested on the hub never letting anyone
|
||||||
|
# else hold that address. `login` makes an email match prove possession of
|
||||||
|
# the existing account first. Grant is unaffected: his account is already
|
||||||
|
# linked by the hub's stable `sub`, which is matched before email.
|
||||||
|
# ⚠️ env-to-ini SETS but never UNSETS — this value must also be edited in
|
||||||
|
# /srv/windygit/git/gitea/conf/app.ini if it is ever removed from here.
|
||||||
|
GITEA__oauth2_client__ACCOUNT_LINKING: login
|
||||||
# The email is asserted by account-server, which is the authority on it.
|
# The email is asserted by account-server, which is the authority on it.
|
||||||
# Asking the user to re-verify an address their identity provider already
|
# Asking the user to re-verify an address their identity provider already
|
||||||
# verified is friction that buys nothing.
|
# verified is friction that buys nothing.
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ WG_OWNER = os.environ.get("WINDYGIT_OWNER", "windyadmin")
|
|||||||
REPOS = os.environ.get(
|
REPOS = os.environ.get(
|
||||||
"BRIDGE_REPOS",
|
"BRIDGE_REPOS",
|
||||||
"windy-chat windy-mail windy-calendar Windy-Clone WindyCloud windy-search windy-connect"
|
"windy-chat windy-mail windy-calendar Windy-Clone WindyCloud windy-search windy-connect"
|
||||||
" windy-drops windy-code-web windy-code windy-traveler",
|
" windy-drops windy-code-web windy-code windy-traveler windy-registry eternitas",
|
||||||
).split()
|
).split()
|
||||||
|
|
||||||
# Gitea run status -> GitHub status state. `skipped` is deliberately absent: a
|
# Gitea run status -> GitHub status state. `skipped` is deliberately absent: a
|
||||||
|
|||||||
Reference in New Issue
Block a user