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
+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]: