Key OIDC identity by issuer and subject

This commit is contained in:
2026-10-25 08:35:11 +00:00
parent dab4b9e0bb
commit 44a79f5b0c
17 changed files with 293 additions and 27 deletions
@@ -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=="
)
+13 -4
View File
@@ -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:
+10 -4
View File
@@ -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})"
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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],
+6 -2
View File
@@ -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:
+1 -1
View File
@@ -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]:
+4 -2
View File
@@ -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})
+111
View File
@@ -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()
+5 -1
View File
@@ -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"])