6033967e6a
Ship Keystone auth, multi-service handlers (Yoga→Dalmatian), Compose/Helm packaging, API contract packs, and pytest/Pulumi coverage labs.
187 lines
6.2 KiB
Python
187 lines
6.2 KiB
Python
"""Keystone token issue / validation helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from asyncpg import Connection
|
|
|
|
from app.openstack.catalog import build_catalog_from_db
|
|
from app.openstack.errors import OpenStackError
|
|
from app.security.auth import verify_secret
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class TokenContext:
|
|
token_id: str
|
|
user_id: UUID
|
|
user_name: str
|
|
project_id: UUID | None
|
|
project_name: str | None
|
|
roles: tuple[str, ...]
|
|
expires_at: datetime
|
|
is_admin: bool
|
|
|
|
|
|
async def issue_token(
|
|
conn: Connection,
|
|
*,
|
|
user_name: str,
|
|
password: str,
|
|
project_name: str | None,
|
|
domain_name: str = "Default",
|
|
host: str = "localhost",
|
|
scheme: str = "http",
|
|
ttl_seconds: int = 3600,
|
|
) -> tuple[str, dict[str, Any]]:
|
|
domain = await conn.fetchrow(
|
|
"SELECT id, name FROM os_domains WHERE name = $1 AND enabled", domain_name
|
|
)
|
|
if domain is None:
|
|
raise OpenStackError("Unauthorized", "Invalid user credentials", status_code=401)
|
|
|
|
user = await conn.fetchrow(
|
|
"""SELECT id, name, password_hash, enabled
|
|
FROM os_users WHERE domain_id = $1 AND name = $2""",
|
|
domain["id"],
|
|
user_name,
|
|
)
|
|
if user is None or not user["enabled"] or not verify_secret(password, user["password_hash"]):
|
|
raise OpenStackError(
|
|
"Unauthorized", "The request you have made requires authentication.", status_code=401
|
|
)
|
|
|
|
project = None
|
|
if project_name:
|
|
project = await conn.fetchrow(
|
|
"""SELECT id, name, enabled FROM os_projects
|
|
WHERE domain_id = $1 AND name = $2""",
|
|
domain["id"],
|
|
project_name,
|
|
)
|
|
if project is None or not project["enabled"]:
|
|
raise OpenStackError("Unauthorized", "Project not found or disabled", status_code=401)
|
|
assignment = await conn.fetchval(
|
|
"""SELECT 1 FROM os_role_assignments
|
|
WHERE user_id = $1 AND project_id = $2 LIMIT 1""",
|
|
user["id"],
|
|
project["id"],
|
|
)
|
|
if assignment is None:
|
|
raise OpenStackError("Forbidden", "User is not authorized for project", status_code=403)
|
|
|
|
roles_rows = []
|
|
if project is not None:
|
|
roles_rows = await conn.fetch(
|
|
"""SELECT r.name FROM os_role_assignments a
|
|
JOIN os_roles r ON r.id = a.role_id
|
|
WHERE a.user_id = $1 AND a.project_id = $2""",
|
|
user["id"],
|
|
project["id"],
|
|
)
|
|
role_names = [str(row["name"]) for row in roles_rows]
|
|
|
|
token_id = secrets.token_hex(16)
|
|
now = datetime.now(UTC)
|
|
expires = now + timedelta(seconds=ttl_seconds)
|
|
await conn.execute(
|
|
"""INSERT INTO os_tokens(id, user_id, project_id, expires_at, issued_at, revoked)
|
|
VALUES($1, $2, $3, $4, $5, false)""",
|
|
token_id,
|
|
user["id"],
|
|
project["id"] if project else None,
|
|
expires,
|
|
now,
|
|
)
|
|
|
|
catalog = await build_catalog_from_db(conn, host, scheme=scheme) if project is not None else []
|
|
body = {
|
|
"token": {
|
|
# Lab convenience: token id also in body so browser UIs need not rely on
|
|
# Access-Control-Expose-Headers for X-Subject-Token.
|
|
"id": token_id,
|
|
"methods": ["password"],
|
|
"expires_at": expires.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
|
"issued_at": now.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
|
"user": {
|
|
"id": str(user["id"]),
|
|
"name": user["name"],
|
|
"domain": {"id": str(domain["id"]), "name": domain["name"]},
|
|
},
|
|
"audit_ids": [secrets.token_urlsafe(8)],
|
|
"roles": [{"id": name, "name": name} for name in role_names],
|
|
}
|
|
}
|
|
if project is not None:
|
|
body["token"]["project"] = {
|
|
"id": str(project["id"]),
|
|
"name": project["name"],
|
|
"domain": {"id": str(domain["id"]), "name": domain["name"]},
|
|
}
|
|
body["token"]["catalog"] = catalog
|
|
return token_id, body
|
|
|
|
|
|
async def validate_token(conn: Connection, token_id: str) -> TokenContext:
|
|
if not token_id:
|
|
raise OpenStackError(
|
|
"Unauthorized",
|
|
"The request you have made requires authentication.",
|
|
status_code=401,
|
|
)
|
|
row = await conn.fetchrow(
|
|
"""SELECT t.id, t.user_id, t.project_id, t.expires_at, t.revoked,
|
|
u.name AS user_name, p.name AS project_name
|
|
FROM os_tokens t
|
|
JOIN os_users u ON u.id = t.user_id
|
|
LEFT JOIN os_projects p ON p.id = t.project_id
|
|
WHERE t.id = $1""",
|
|
token_id,
|
|
)
|
|
if row is None or row["revoked"]:
|
|
raise OpenStackError("Unauthorized", "Invalid token", status_code=401)
|
|
expires = row["expires_at"]
|
|
if expires.tzinfo is None:
|
|
expires = expires.replace(tzinfo=UTC)
|
|
if expires <= datetime.now(UTC):
|
|
raise OpenStackError("Unauthorized", "Token has expired", status_code=401)
|
|
|
|
roles: list[str] = []
|
|
if row["project_id"] is not None:
|
|
roles = [
|
|
str(r["name"])
|
|
for r in await conn.fetch(
|
|
"""SELECT r.name FROM os_role_assignments a
|
|
JOIN os_roles r ON r.id = a.role_id
|
|
WHERE a.user_id = $1 AND a.project_id = $2""",
|
|
row["user_id"],
|
|
row["project_id"],
|
|
)
|
|
]
|
|
is_admin = "admin" in roles
|
|
return TokenContext(
|
|
token_id=str(row["id"]),
|
|
user_id=row["user_id"],
|
|
user_name=str(row["user_name"]),
|
|
project_id=row["project_id"],
|
|
project_name=str(row["project_name"]) if row["project_name"] else None,
|
|
roles=tuple(roles),
|
|
expires_at=expires,
|
|
is_admin=is_admin,
|
|
)
|
|
|
|
|
|
def extract_token(headers: dict[str, str]) -> str | None:
|
|
# Case-insensitive lookup
|
|
lower = {k.lower(): v for k, v in headers.items()}
|
|
if "x-auth-token" in lower:
|
|
return lower["x-auth-token"]
|
|
auth = lower.get("authorization", "")
|
|
if auth.lower().startswith("bearer "):
|
|
return auth.split(" ", 1)[1].strip()
|
|
return None
|