diff --git a/server/migrations/models/4_20261025081154_oidc_issuer_identity.py b/server/migrations/models/4_20261025081154_oidc_issuer_identity.py new file mode 100644 index 0000000..7bf7f6b --- /dev/null +++ b/server/migrations/models/4_20261025081154_oidc_issuer_identity.py @@ -0,0 +1,111 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + +# Legacy rows have no issuer to backfill. Reset disposable test databases before +# applying this schema-only migration. + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX IF EXISTS "idx_vault_owner_s_cdc1f8"; + ALTER TABLE "app_user" DROP CONSTRAINT IF EXISTS "app_user_sub_key"; + DROP INDEX IF EXISTS "uid_app_user_sub_b936a8"; + DROP INDEX IF EXISTS "idx_api_token_user_su_7c5de7"; + ALTER TABLE "api_token" ADD "user_issuer" VARCHAR(2048) NOT NULL; + ALTER TABLE "app_user" ADD "issuer" VARCHAR(2048) NOT NULL; + ALTER TABLE "vault" ADD "owner_issuer" VARCHAR(2048) NOT NULL; + CREATE INDEX IF NOT EXISTS "idx_api_token_user_is_612f0d" ON "api_token" ("user_issuer", "user_sub"); + CREATE UNIQUE INDEX IF NOT EXISTS "uid_app_user_issuer_d243fe" ON "app_user" ("issuer", "sub"); + CREATE INDEX IF NOT EXISTS "idx_vault_owner_i_a035fb" ON "vault" ("owner_issuer", "owner_sub");""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX IF EXISTS "idx_vault_owner_i_a035fb"; + DROP INDEX IF EXISTS "uid_app_user_issuer_d243fe"; + DROP INDEX IF EXISTS "idx_api_token_user_is_612f0d"; + ALTER TABLE "api_token" DROP COLUMN "user_issuer"; + ALTER TABLE "app_user" DROP COLUMN "issuer"; + ALTER TABLE "vault" DROP COLUMN "owner_issuer"; + CREATE INDEX IF NOT EXISTS "idx_api_token_user_su_7c5de7" ON "api_token" ("user_sub"); + CREATE UNIQUE INDEX IF NOT EXISTS "uid_app_user_sub_b936a8" ON "app_user" ("sub"); + CREATE INDEX IF NOT EXISTS "idx_vault_owner_s_cdc1f8" ON "vault" ("owner_sub");""" + + +MODELS_STATE = ( + "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" + "cVQA==" +) diff --git a/server/packages/kca-app/src/kca/auth.py b/server/packages/kca-app/src/kca/auth.py index 0adc013..5c3b959 100644 --- a/server/packages/kca-app/src/kca/auth.py +++ b/server/packages/kca-app/src/kca/auth.py @@ -105,13 +105,22 @@ def current_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[OIDCUser]: return next(iter(oidc.get_users(ctx).values()), None) -async def ensure_user(user: OIDCUser) -> User: +async def ensure_user(user: OIDCUser) -> Optional[User]: + issuer = user.get("iss") + if not isinstance(issuer, str) or not issuer: + log.warning("rejected OIDC identity without an issuer") + return None updates: Dict[str, Any] = profile_claims(user) updates["display_name"] = display_name(user) - row = await User.get_or_none(sub=user.sub) + row = await User.get_or_none(issuer=issuer, sub=user.sub) if row is None: - row = await User.create(sub=user.sub, **updates) - log.info("registered user sub=%s name=%r", user.sub, updates["display_name"]) + row = await User.create(issuer=issuer, sub=user.sub, **updates) + log.info( + "registered user issuer=%s sub=%s name=%r", + issuer, + user.sub, + updates["display_name"], + ) return row changed = [field for field, value in updates.items() if getattr(row, field) != value] if changed: diff --git a/server/packages/kca-app/src/kca/models.py b/server/packages/kca-app/src/kca/models.py index 9d1debd..a669019 100644 --- a/server/packages/kca-app/src/kca/models.py +++ b/server/packages/kca-app/src/kca/models.py @@ -18,7 +18,8 @@ class User(Model): """One OIDC identity that has logged in at least once.""" id = fields.UUIDField(pk=True) - sub = fields.CharField(max_length=255, unique=True) + issuer = fields.CharField(max_length=2048) + sub = fields.CharField(max_length=255) display_name = fields.CharField(max_length=200) name = fields.CharField(max_length=255, null=True) @@ -42,16 +43,18 @@ class User(Model): class Meta: table = "app_user" ordering = ["display_name", "id"] + unique_together = (("issuer", "sub"),) def __str__(self) -> str: - return f"User(sub={self.sub!r}, display_name={self.display_name!r})" + return f"User(issuer={self.issuer!r}, sub={self.sub!r}, display_name={self.display_name!r})" class ApiToken(Model): """Personal access token for programmatic API access.""" id = fields.UUIDField(pk=True) - user_sub = fields.CharField(max_length=255, db_index=True) + user_issuer = fields.CharField(max_length=2048) + user_sub = fields.CharField(max_length=255) name = fields.CharField(max_length=100) token_prefix = fields.CharField(max_length=16) token_hash = fields.CharField(max_length=64, unique=True) @@ -62,6 +65,7 @@ class ApiToken(Model): class Meta: table = "api_token" ordering = ["-created_at", "id"] + indexes = (("user_issuer", "user_sub"),) def __str__(self) -> str: return f"ApiToken(name={self.name!r}, prefix={self.token_prefix!r})" @@ -71,7 +75,8 @@ class Vault(Model): """One private XCA database owned by an OIDC user.""" id = fields.UUIDField(pk=True) - owner_sub = fields.CharField(max_length=255, db_index=True) + owner_issuer = fields.CharField(max_length=2048) + owner_sub = fields.CharField(max_length=255) name = fields.CharField(max_length=200) description = fields.TextField(default="") settings = fields.JSONField(default=dict) @@ -88,6 +93,7 @@ class Vault(Model): class Meta: table = "vault" ordering = ["name", "id"] + indexes = (("owner_issuer", "owner_sub"),) def __str__(self) -> str: return f"Vault(name={self.name!r})" diff --git a/server/packages/kca-app/src/kca/routes/certificates.py b/server/packages/kca-app/src/kca/routes/certificates.py index b9948f4..b02d2d7 100644 --- a/server/packages/kca-app/src/kca/routes/certificates.py +++ b/server/packages/kca-app/src/kca/routes/certificates.py @@ -53,7 +53,7 @@ if TYPE_CHECKING: async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str): user = await authenticated_user(kca.oidc, ctx) assert user is not None - vault = await vault_for_owner(user.sub, parse_uuid(vault_id)) + vault = await vault_for_owner(user, parse_uuid(vault_id)) if vault is None: await send_error(ctx, 404, "vault not found") return vault diff --git a/server/packages/kca-app/src/kca/routes/crls.py b/server/packages/kca-app/src/kca/routes/crls.py index 7f3c29b..3fc3ac1 100644 --- a/server/packages/kca-app/src/kca/routes/crls.py +++ b/server/packages/kca-app/src/kca/routes/crls.py @@ -28,7 +28,7 @@ if TYPE_CHECKING: async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str): user = await authenticated_user(kca.oidc, ctx) assert user is not None - vault = await vault_for_owner(user.sub, parse_uuid(vault_id)) + vault = await vault_for_owner(user, parse_uuid(vault_id)) if vault is None: await send_error(ctx, 404, "vault not found") return vault diff --git a/server/packages/kca-app/src/kca/routes/import_.py b/server/packages/kca-app/src/kca/routes/import_.py index 35fab46..896fd1d 100644 --- a/server/packages/kca-app/src/kca/routes/import_.py +++ b/server/packages/kca-app/src/kca/routes/import_.py @@ -32,7 +32,7 @@ if TYPE_CHECKING: async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str): user = await authenticated_user(kca.oidc, ctx) assert user is not None - vault = await vault_for_owner(user.sub, parse_uuid(vault_id)) + vault = await vault_for_owner(user, parse_uuid(vault_id)) if vault is None: await send_error(ctx, 404, "vault not found") return vault diff --git a/server/packages/kca-app/src/kca/routes/keys.py b/server/packages/kca-app/src/kca/routes/keys.py index c120a66..58eb592 100644 --- a/server/packages/kca-app/src/kca/routes/keys.py +++ b/server/packages/kca-app/src/kca/routes/keys.py @@ -37,7 +37,7 @@ _keygen_semaphore = asyncio.Semaphore(2) async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str): user = await authenticated_user(kca.oidc, ctx) assert user is not None - vault = await vault_for_owner(user.sub, parse_uuid(vault_id)) + vault = await vault_for_owner(user, parse_uuid(vault_id)) if vault is None: await send_error(ctx, 404, "vault not found") return vault diff --git a/server/packages/kca-app/src/kca/routes/requests.py b/server/packages/kca-app/src/kca/routes/requests.py index 69b5b8f..e322e71 100644 --- a/server/packages/kca-app/src/kca/routes/requests.py +++ b/server/packages/kca-app/src/kca/routes/requests.py @@ -42,7 +42,7 @@ _keygen_semaphore = asyncio.Semaphore(2) async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str): user = await authenticated_user(kca.oidc, ctx) assert user is not None - vault = await vault_for_owner(user.sub, parse_uuid(vault_id)) + vault = await vault_for_owner(user, parse_uuid(vault_id)) if vault is None: await send_error(ctx, 404, "vault not found") return vault diff --git a/server/packages/kca-app/src/kca/routes/search.py b/server/packages/kca-app/src/kca/routes/search.py index 3559bce..032674c 100644 --- a/server/packages/kca-app/src/kca/routes/search.py +++ b/server/packages/kca-app/src/kca/routes/search.py @@ -34,7 +34,7 @@ def register(app: KayaApp, kca: "Kca") -> None: async def search(ctx: HttpContext, vault_id: str) -> None: user = await authenticated_user(kca.oidc, ctx) assert user is not None - vault = await vault_for_owner(user.sub, parse_uuid(vault_id)) + vault = await vault_for_owner(user, parse_uuid(vault_id)) if vault is None: await send_error(ctx, 404, "vault not found") return diff --git a/server/packages/kca-app/src/kca/routes/templates.py b/server/packages/kca-app/src/kca/routes/templates.py index 97e2301..b44c40c 100644 --- a/server/packages/kca-app/src/kca/routes/templates.py +++ b/server/packages/kca-app/src/kca/routes/templates.py @@ -30,7 +30,7 @@ if TYPE_CHECKING: async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str): user = await authenticated_user(kca.oidc, ctx) assert user is not None - vault = await vault_for_owner(user.sub, parse_uuid(vault_id)) + vault = await vault_for_owner(user, parse_uuid(vault_id)) if vault is None: await send_error(ctx, 404, "vault not found") return vault diff --git a/server/packages/kca-app/src/kca/routes/tokens.py b/server/packages/kca-app/src/kca/routes/tokens.py index 5d1c98f..a394736 100644 --- a/server/packages/kca-app/src/kca/routes/tokens.py +++ b/server/packages/kca-app/src/kca/routes/tokens.py @@ -42,7 +42,7 @@ def register(app: KayaApp, kca: "Kca") -> None: async def list_tokens(ctx: HttpContext) -> None: user = await authenticated_user(kca.oidc, ctx) assert user is not None - rows = await ApiToken.filter(user_sub=user.sub) + rows = await ApiToken.filter(user_issuer=user.issuer, user_sub=user.sub) await send_json(ctx, 200, {"results": [serialize_token(token) for token in rows]}) @app.POST("/api/tokens") @@ -64,18 +64,25 @@ def register(app: KayaApp, kca: "Kca") -> None: except TokenValidationError as exc: await send_error(ctx, 400, str(exc)) return - if await ApiToken.filter(user_sub=user.sub).count() >= MAX_TOKENS_PER_USER: + if await ApiToken.filter(user_issuer=user.issuer, user_sub=user.sub).count() >= MAX_TOKENS_PER_USER: await send_error(ctx, 400, f"you already have {MAX_TOKENS_PER_USER} tokens; revoke unused ones first") return secret, prefix, digest = generate_token() token = await ApiToken.create( + user_issuer=user.issuer, user_sub=user.sub, name=values["name"], token_prefix=prefix, token_hash=digest, expires_at=values["expires_at"], ) - log.info("created api token sub=%s name=%r prefix=%s", user.sub, token.name, token.token_prefix) + log.info( + "created api token issuer=%s sub=%s name=%r prefix=%s", + user.issuer, + user.sub, + token.name, + token.token_prefix, + ) payload = serialize_token(token) payload["token"] = secret await send_json(ctx, 201, payload) @@ -90,7 +97,15 @@ def register(app: KayaApp, kca: "Kca") -> None: user = await authenticated_user(kca.oidc, ctx) assert user is not None token_uuid = _parse_uuid(token_id) - token = await ApiToken.get_or_none(id=token_uuid, user_sub=user.sub) if token_uuid else None + token = ( + await ApiToken.get_or_none( + id=token_uuid, + user_issuer=user.issuer, + user_sub=user.sub, + ) + if token_uuid + else None + ) if token is None: await send_error(ctx, 404, "token not found") return diff --git a/server/packages/kca-app/src/kca/routes/vaults.py b/server/packages/kca-app/src/kca/routes/vaults.py index a60b247..6bf8e92 100644 --- a/server/packages/kca-app/src/kca/routes/vaults.py +++ b/server/packages/kca-app/src/kca/routes/vaults.py @@ -51,7 +51,7 @@ def _parse_vault_id(value: str): async def _vault_or_error(kca: "Kca", ctx: HttpContext, vault_id: str) -> Optional[Vault]: user = await authenticated_user(kca.oidc, ctx) assert user is not None - vault = await vault_for_owner(user.sub, _parse_vault_id(vault_id)) + vault = await vault_for_owner(user, _parse_vault_id(vault_id)) if vault is None: await send_error(ctx, 404, "vault not found") return vault @@ -66,7 +66,10 @@ def register(app: KayaApp, kca: "Kca") -> None: async def list_vaults(ctx: HttpContext) -> None: user = await authenticated_user(kca.oidc, ctx) assert user is not None - vaults = await Vault.filter(owner_sub=user.sub).order_by("name", "id") + vaults = await Vault.filter( + owner_issuer=user.issuer, + owner_sub=user.sub, + ).order_by("name", "id") await send_json(ctx, 200, {"results": [await serialize_vault_summary(v) for v in vaults]}) @app.POST("/api/vaults") @@ -99,6 +102,7 @@ def register(app: KayaApp, kca: "Kca") -> None: await send_error(ctx, 400, str(exc)) return vault = await Vault.create( + owner_issuer=user.issuer, owner_sub=user.sub, name=name.strip()[:200], description=description[:4000], diff --git a/server/packages/kca-app/src/kca/service.py b/server/packages/kca-app/src/kca/service.py index bad62f8..7218027 100644 --- a/server/packages/kca-app/src/kca/service.py +++ b/server/packages/kca-app/src/kca/service.py @@ -142,10 +142,14 @@ def extension_search_text(extensions: Iterable[Any]) -> str: return " ".join(parts) -async def vault_for_owner(owner_sub: str, vault_id: Optional[UUID]) -> Optional[Vault]: +async def vault_for_owner(user: User, vault_id: Optional[UUID]) -> Optional[Vault]: if vault_id is None: return None - return await Vault.get_or_none(id=vault_id, owner_sub=owner_sub) + return await Vault.get_or_none( + id=vault_id, + owner_issuer=user.issuer, + owner_sub=user.sub, + ) async def key_use_count(key_id: UUID) -> int: diff --git a/server/packages/kca-app/src/kca/tokens.py b/server/packages/kca-app/src/kca/tokens.py index 20a83c0..e272801 100644 --- a/server/packages/kca-app/src/kca/tokens.py +++ b/server/packages/kca-app/src/kca/tokens.py @@ -55,7 +55,7 @@ async def resolve_token(secret: str) -> Optional[User]: if last_used is None or now - last_used >= LAST_USED_REFRESH: token.last_used_at = now await token.save(update_fields=["last_used_at"]) - return await User.get_or_none(sub=token.user_sub) + return await User.get_or_none(issuer=token.user_issuer, sub=token.user_sub) def parse_token_payload(payload: Mapping[str, Any]) -> Dict[str, Any]: diff --git a/server/tests/helpers/oidc.py b/server/tests/helpers/oidc.py index 3f51617..fd43bd5 100644 --- a/server/tests/helpers/oidc.py +++ b/server/tests/helpers/oidc.py @@ -8,12 +8,14 @@ from typing import Any, Iterator, Optional from kaya.oidc import OIDCUser from kca.app import oidc +from kca.config import settings _PROVIDER = next(iter(oidc.providers)) +_ISSUER = settings.oidc_providers[0].issuer def make_user(sub: str, name: Optional[str] = None, **claims: Any) -> OIDCUser: - data: dict = {"sub": sub, "preferred_username": name or sub} + data: dict = {"iss": _ISSUER, "sub": sub, "preferred_username": name or sub} data.update(claims) return OIDCUser(data) @@ -24,7 +26,7 @@ def oidc_user( name: Optional[str] = None, **claims: Any, ) -> Iterator[OIDCUser]: - data: dict = {"sub": sub, "preferred_username": name or sub} + data: dict = {"iss": _ISSUER, "sub": sub, "preferred_username": name or sub} data.update(claims) user = OIDCUser(data) patcher = _mock.patch.object(oidc, "get_users", return_value={_PROVIDER: user}) diff --git a/server/tests/test_identity.py b/server/tests/test_identity.py new file mode 100644 index 0000000..4d5a5b6 --- /dev/null +++ b/server/tests/test_identity.py @@ -0,0 +1,111 @@ +"""OIDC issuer and subject identity isolation tests.""" +from __future__ import annotations + +import unittest + +from kca.models import ApiToken, User +from kca.tokens import MAX_TOKENS_PER_USER +from tests.helpers.api import api_client +from tests.helpers.asynctest import async_test +from tests.helpers.oidc import oidc_user + + +class IdentityIsolationTests(unittest.TestCase): + @async_test + async def test_same_subject_at_different_issuers_is_isolated(self) -> None: + issuer_a = "https://issuer-a.example" + issuer_b = "https://issuer-b.example" + + async with api_client() as client: + with oidc_user("shared-subject", "Issuer A User", iss=issuer_a): + response = await client.post("/api/vaults", json={"name": "A vault"}) + assert response.status_code == 201, response.text + vault_a = response.json()["id"] + response = await client.post( + f"/api/vaults/{vault_a}/keys", + json={"name": "A private key", "key_type": "ec", "curve": "secp256r1"}, + ) + assert response.status_code == 201, response.text + key_a = response.json()["id"] + response = await client.post("/api/tokens", json={"name": "A token"}) + assert response.status_code == 201, response.text + token_a = response.json() + + with oidc_user("shared-subject", "Issuer B User", iss=issuer_b): + response = await client.get("/api/vaults") + assert response.status_code == 200 + assert response.json()["results"] == [] + + response = await client.get(f"/api/vaults/{vault_a}") + assert response.status_code == 404 + response = await client.post( + f"/api/vaults/{vault_a}/keys/{key_a}/export", + json={"format": "pem-private"}, + ) + assert response.status_code == 404 + + response = await client.get("/api/tokens") + assert response.status_code == 200 + assert response.json()["results"] == [] + response = await client.delete(f"/api/tokens/{token_a['id']}") + assert response.status_code == 404 + + response = await client.post("/api/vaults", json={"name": "B vault"}) + assert response.status_code == 201, response.text + vault_b = response.json()["id"] + response = await client.post("/api/tokens", json={"name": "B token"}) + assert response.status_code == 201, response.text + token_b = response.json() + + users = await User.filter(sub="shared-subject") + assert len(users) == 2 + assert {user.issuer for user in users} == {issuer_a, issuer_b} + assert {user.display_name for user in users} == {"Issuer A User", "Issuer B User"} + + for token, expected_vault in ((token_a, vault_a), (token_b, vault_b)): + response = await client.get( + "/api/vaults", + headers={"authorization": f"Bearer {token['token']}"}, + ) + assert response.status_code == 200, response.text + assert [vault["id"] for vault in response.json()["results"]] == [expected_vault] + + @async_test + async def test_token_limit_is_scoped_to_issuer(self) -> None: + issuer_a = "https://issuer-a.example" + issuer_b = "https://issuer-b.example" + + async with api_client() as client: + with oidc_user("limited-subject", iss=issuer_a): + response = await client.get("/api/me") + assert response.status_code == 200 + await ApiToken.bulk_create( + [ + ApiToken( + user_issuer=issuer_a, + user_sub="limited-subject", + name=f"token {number}", + token_prefix=f"prefix-{number}", + token_hash=f"{number:064x}", + ) + for number in range(MAX_TOKENS_PER_USER) + ] + ) + response = await client.post("/api/tokens", json={"name": "over limit"}) + assert response.status_code == 400 + + with oidc_user("limited-subject", iss=issuer_b): + response = await client.post("/api/tokens", json={"name": "independent"}) + assert response.status_code == 201, response.text + + @async_test + async def test_identity_without_issuer_is_rejected(self) -> None: + async with api_client() as client: + with oidc_user("missing-issuer", iss=None): + response = await client.get("/api/me") + assert response.status_code == 401 + assert await User.filter(sub="missing-issuer").count() == 0 + + +if __name__ == "__main__": + unittest.main() diff --git a/server/tests/test_telemetry.py b/server/tests/test_telemetry.py index 9f028a4..ea31a27 100644 --- a/server/tests/test_telemetry.py +++ b/server/tests/test_telemetry.py @@ -186,7 +186,11 @@ class SessionTagHookTest(unittest.TestCase): def test_tags_token_user_cached_on_request(self) -> None: span = _RecordedSpan() carrier = SimpleNamespace(session=Session("sid", {})) - setattr(carrier, "_kca_user", User(sub="token-user", display_name="Token")) + setattr( + carrier, + "_kca_user", + User(issuer="https://issuer.example", sub="token-user", display_name="Token"), + ) session_tag_hook(span, carrier) self.assertEqual("token-user", span.attributes["user.id"])