Key OIDC identity by issuer and subject
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user