Key OIDC identity by issuer and subject
This commit is contained in:
@@ -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=="
|
||||||
|
)
|
||||||
@@ -105,13 +105,22 @@ def current_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[OIDCUser]:
|
|||||||
return next(iter(oidc.get_users(ctx).values()), None)
|
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: Dict[str, Any] = profile_claims(user)
|
||||||
updates["display_name"] = display_name(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:
|
if row is None:
|
||||||
row = await User.create(sub=user.sub, **updates)
|
row = await User.create(issuer=issuer, sub=user.sub, **updates)
|
||||||
log.info("registered user sub=%s name=%r", user.sub, updates["display_name"])
|
log.info(
|
||||||
|
"registered user issuer=%s sub=%s name=%r",
|
||||||
|
issuer,
|
||||||
|
user.sub,
|
||||||
|
updates["display_name"],
|
||||||
|
)
|
||||||
return row
|
return row
|
||||||
changed = [field for field, value in updates.items() if getattr(row, field) != value]
|
changed = [field for field, value in updates.items() if getattr(row, field) != value]
|
||||||
if changed:
|
if changed:
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ class User(Model):
|
|||||||
"""One OIDC identity that has logged in at least once."""
|
"""One OIDC identity that has logged in at least once."""
|
||||||
|
|
||||||
id = fields.UUIDField(pk=True)
|
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)
|
display_name = fields.CharField(max_length=200)
|
||||||
|
|
||||||
name = fields.CharField(max_length=255, null=True)
|
name = fields.CharField(max_length=255, null=True)
|
||||||
@@ -42,16 +43,18 @@ class User(Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
table = "app_user"
|
table = "app_user"
|
||||||
ordering = ["display_name", "id"]
|
ordering = ["display_name", "id"]
|
||||||
|
unique_together = (("issuer", "sub"),)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
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):
|
class ApiToken(Model):
|
||||||
"""Personal access token for programmatic API access."""
|
"""Personal access token for programmatic API access."""
|
||||||
|
|
||||||
id = fields.UUIDField(pk=True)
|
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)
|
name = fields.CharField(max_length=100)
|
||||||
token_prefix = fields.CharField(max_length=16)
|
token_prefix = fields.CharField(max_length=16)
|
||||||
token_hash = fields.CharField(max_length=64, unique=True)
|
token_hash = fields.CharField(max_length=64, unique=True)
|
||||||
@@ -62,6 +65,7 @@ class ApiToken(Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
table = "api_token"
|
table = "api_token"
|
||||||
ordering = ["-created_at", "id"]
|
ordering = ["-created_at", "id"]
|
||||||
|
indexes = (("user_issuer", "user_sub"),)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"ApiToken(name={self.name!r}, prefix={self.token_prefix!r})"
|
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."""
|
"""One private XCA database owned by an OIDC user."""
|
||||||
|
|
||||||
id = fields.UUIDField(pk=True)
|
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)
|
name = fields.CharField(max_length=200)
|
||||||
description = fields.TextField(default="")
|
description = fields.TextField(default="")
|
||||||
settings = fields.JSONField(default=dict)
|
settings = fields.JSONField(default=dict)
|
||||||
@@ -88,6 +93,7 @@ class Vault(Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
table = "vault"
|
table = "vault"
|
||||||
ordering = ["name", "id"]
|
ordering = ["name", "id"]
|
||||||
|
indexes = (("owner_issuer", "owner_sub"),)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"Vault(name={self.name!r})"
|
return f"Vault(name={self.name!r})"
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ if TYPE_CHECKING:
|
|||||||
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
|
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
|
||||||
user = await authenticated_user(kca.oidc, ctx)
|
user = await authenticated_user(kca.oidc, ctx)
|
||||||
assert user is not None
|
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:
|
if vault is None:
|
||||||
await send_error(ctx, 404, "vault not found")
|
await send_error(ctx, 404, "vault not found")
|
||||||
return vault
|
return vault
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ if TYPE_CHECKING:
|
|||||||
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
|
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
|
||||||
user = await authenticated_user(kca.oidc, ctx)
|
user = await authenticated_user(kca.oidc, ctx)
|
||||||
assert user is not None
|
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:
|
if vault is None:
|
||||||
await send_error(ctx, 404, "vault not found")
|
await send_error(ctx, 404, "vault not found")
|
||||||
return vault
|
return vault
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ if TYPE_CHECKING:
|
|||||||
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
|
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
|
||||||
user = await authenticated_user(kca.oidc, ctx)
|
user = await authenticated_user(kca.oidc, ctx)
|
||||||
assert user is not None
|
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:
|
if vault is None:
|
||||||
await send_error(ctx, 404, "vault not found")
|
await send_error(ctx, 404, "vault not found")
|
||||||
return vault
|
return vault
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ _keygen_semaphore = asyncio.Semaphore(2)
|
|||||||
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
|
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
|
||||||
user = await authenticated_user(kca.oidc, ctx)
|
user = await authenticated_user(kca.oidc, ctx)
|
||||||
assert user is not None
|
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:
|
if vault is None:
|
||||||
await send_error(ctx, 404, "vault not found")
|
await send_error(ctx, 404, "vault not found")
|
||||||
return vault
|
return vault
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ _keygen_semaphore = asyncio.Semaphore(2)
|
|||||||
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
|
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
|
||||||
user = await authenticated_user(kca.oidc, ctx)
|
user = await authenticated_user(kca.oidc, ctx)
|
||||||
assert user is not None
|
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:
|
if vault is None:
|
||||||
await send_error(ctx, 404, "vault not found")
|
await send_error(ctx, 404, "vault not found")
|
||||||
return vault
|
return vault
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ def register(app: KayaApp, kca: "Kca") -> None:
|
|||||||
async def search(ctx: HttpContext, vault_id: str) -> None:
|
async def search(ctx: HttpContext, vault_id: str) -> None:
|
||||||
user = await authenticated_user(kca.oidc, ctx)
|
user = await authenticated_user(kca.oidc, ctx)
|
||||||
assert user is not None
|
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:
|
if vault is None:
|
||||||
await send_error(ctx, 404, "vault not found")
|
await send_error(ctx, 404, "vault not found")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ if TYPE_CHECKING:
|
|||||||
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
|
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
|
||||||
user = await authenticated_user(kca.oidc, ctx)
|
user = await authenticated_user(kca.oidc, ctx)
|
||||||
assert user is not None
|
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:
|
if vault is None:
|
||||||
await send_error(ctx, 404, "vault not found")
|
await send_error(ctx, 404, "vault not found")
|
||||||
return vault
|
return vault
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def register(app: KayaApp, kca: "Kca") -> None:
|
|||||||
async def list_tokens(ctx: HttpContext) -> None:
|
async def list_tokens(ctx: HttpContext) -> None:
|
||||||
user = await authenticated_user(kca.oidc, ctx)
|
user = await authenticated_user(kca.oidc, ctx)
|
||||||
assert user is not None
|
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]})
|
await send_json(ctx, 200, {"results": [serialize_token(token) for token in rows]})
|
||||||
|
|
||||||
@app.POST("/api/tokens")
|
@app.POST("/api/tokens")
|
||||||
@@ -64,18 +64,25 @@ def register(app: KayaApp, kca: "Kca") -> None:
|
|||||||
except TokenValidationError as exc:
|
except TokenValidationError as exc:
|
||||||
await send_error(ctx, 400, str(exc))
|
await send_error(ctx, 400, str(exc))
|
||||||
return
|
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")
|
await send_error(ctx, 400, f"you already have {MAX_TOKENS_PER_USER} tokens; revoke unused ones first")
|
||||||
return
|
return
|
||||||
secret, prefix, digest = generate_token()
|
secret, prefix, digest = generate_token()
|
||||||
token = await ApiToken.create(
|
token = await ApiToken.create(
|
||||||
|
user_issuer=user.issuer,
|
||||||
user_sub=user.sub,
|
user_sub=user.sub,
|
||||||
name=values["name"],
|
name=values["name"],
|
||||||
token_prefix=prefix,
|
token_prefix=prefix,
|
||||||
token_hash=digest,
|
token_hash=digest,
|
||||||
expires_at=values["expires_at"],
|
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 = serialize_token(token)
|
||||||
payload["token"] = secret
|
payload["token"] = secret
|
||||||
await send_json(ctx, 201, payload)
|
await send_json(ctx, 201, payload)
|
||||||
@@ -90,7 +97,15 @@ def register(app: KayaApp, kca: "Kca") -> None:
|
|||||||
user = await authenticated_user(kca.oidc, ctx)
|
user = await authenticated_user(kca.oidc, ctx)
|
||||||
assert user is not None
|
assert user is not None
|
||||||
token_uuid = _parse_uuid(token_id)
|
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:
|
if token is None:
|
||||||
await send_error(ctx, 404, "token not found")
|
await send_error(ctx, 404, "token not found")
|
||||||
return
|
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]:
|
async def _vault_or_error(kca: "Kca", ctx: HttpContext, vault_id: str) -> Optional[Vault]:
|
||||||
user = await authenticated_user(kca.oidc, ctx)
|
user = await authenticated_user(kca.oidc, ctx)
|
||||||
assert user is not None
|
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:
|
if vault is None:
|
||||||
await send_error(ctx, 404, "vault not found")
|
await send_error(ctx, 404, "vault not found")
|
||||||
return vault
|
return vault
|
||||||
@@ -66,7 +66,10 @@ def register(app: KayaApp, kca: "Kca") -> None:
|
|||||||
async def list_vaults(ctx: HttpContext) -> None:
|
async def list_vaults(ctx: HttpContext) -> None:
|
||||||
user = await authenticated_user(kca.oidc, ctx)
|
user = await authenticated_user(kca.oidc, ctx)
|
||||||
assert user is not None
|
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]})
|
await send_json(ctx, 200, {"results": [await serialize_vault_summary(v) for v in vaults]})
|
||||||
|
|
||||||
@app.POST("/api/vaults")
|
@app.POST("/api/vaults")
|
||||||
@@ -99,6 +102,7 @@ def register(app: KayaApp, kca: "Kca") -> None:
|
|||||||
await send_error(ctx, 400, str(exc))
|
await send_error(ctx, 400, str(exc))
|
||||||
return
|
return
|
||||||
vault = await Vault.create(
|
vault = await Vault.create(
|
||||||
|
owner_issuer=user.issuer,
|
||||||
owner_sub=user.sub,
|
owner_sub=user.sub,
|
||||||
name=name.strip()[:200],
|
name=name.strip()[:200],
|
||||||
description=description[:4000],
|
description=description[:4000],
|
||||||
|
|||||||
@@ -142,10 +142,14 @@ def extension_search_text(extensions: Iterable[Any]) -> str:
|
|||||||
return " ".join(parts)
|
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:
|
if vault_id is None:
|
||||||
return 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:
|
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:
|
if last_used is None or now - last_used >= LAST_USED_REFRESH:
|
||||||
token.last_used_at = now
|
token.last_used_at = now
|
||||||
await token.save(update_fields=["last_used_at"])
|
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]:
|
def parse_token_payload(payload: Mapping[str, Any]) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ from typing import Any, Iterator, Optional
|
|||||||
from kaya.oidc import OIDCUser
|
from kaya.oidc import OIDCUser
|
||||||
|
|
||||||
from kca.app import oidc
|
from kca.app import oidc
|
||||||
|
from kca.config import settings
|
||||||
|
|
||||||
_PROVIDER = next(iter(oidc.providers))
|
_PROVIDER = next(iter(oidc.providers))
|
||||||
|
_ISSUER = settings.oidc_providers[0].issuer
|
||||||
|
|
||||||
|
|
||||||
def make_user(sub: str, name: Optional[str] = None, **claims: Any) -> OIDCUser:
|
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)
|
data.update(claims)
|
||||||
return OIDCUser(data)
|
return OIDCUser(data)
|
||||||
|
|
||||||
@@ -24,7 +26,7 @@ def oidc_user(
|
|||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
**claims: Any,
|
**claims: Any,
|
||||||
) -> Iterator[OIDCUser]:
|
) -> 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)
|
data.update(claims)
|
||||||
user = OIDCUser(data)
|
user = OIDCUser(data)
|
||||||
patcher = _mock.patch.object(oidc, "get_users", return_value={_PROVIDER: user})
|
patcher = _mock.patch.object(oidc, "get_users", return_value={_PROVIDER: user})
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -186,7 +186,11 @@ class SessionTagHookTest(unittest.TestCase):
|
|||||||
def test_tags_token_user_cached_on_request(self) -> None:
|
def test_tags_token_user_cached_on_request(self) -> None:
|
||||||
span = _RecordedSpan()
|
span = _RecordedSpan()
|
||||||
carrier = SimpleNamespace(session=Session("sid", {}))
|
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)
|
session_tag_hook(span, carrier)
|
||||||
self.assertEqual("token-user", span.attributes["user.id"])
|
self.assertEqual("token-user", span.attributes["user.id"])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user