6033967e6a
Ship Keystone auth, multi-service handlers (Yoga→Dalmatian), Compose/Helm packaging, API contract packs, and pytest/Pulumi coverage labs.
478 lines
16 KiB
Python
478 lines
16 KiB
Python
"""Keystone Identity API v3 (lab subset)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Annotated, Any
|
|
|
|
from asyncpg import Connection, Pool
|
|
from fastapi import APIRouter, Depends, Request, Response
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from app.openstack.auth import extract_token, issue_token, validate_token
|
|
from app.openstack.catalog import build_catalog_from_db
|
|
from app.openstack.db_docs import require_doc
|
|
from app.openstack.deps import (
|
|
get_conn,
|
|
get_pool,
|
|
request_public_host,
|
|
request_scheme,
|
|
require_token,
|
|
)
|
|
from app.openstack.errors import OpenStackError
|
|
from app.openstack.auth import TokenContext
|
|
|
|
router = APIRouter(tags=["Keystone"])
|
|
|
|
|
|
@router.get("/v3")
|
|
@router.get("/v3/")
|
|
async def v3_root(
|
|
request: Request,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
) -> dict[str, object]:
|
|
doc = await require_doc(
|
|
conn, service="keystone", resource_type="discovery_version", name="default"
|
|
)
|
|
# Prefer nested version object when present; otherwise wrap values[0].
|
|
if "version" in doc:
|
|
return doc
|
|
values = (doc.get("versions") or {}).get("values") or []
|
|
if values:
|
|
host = request_public_host(request)
|
|
scheme = request_scheme(request)
|
|
version = dict(values[0])
|
|
version["links"] = [{"rel": "self", "href": f"{scheme}://{host}:5000/v3/"}]
|
|
return {"version": version}
|
|
return doc
|
|
|
|
|
|
@router.post("/v3/auth/tokens")
|
|
async def create_token(
|
|
request: Request,
|
|
pool: Annotated[Pool, Depends(get_pool)],
|
|
) -> Response:
|
|
payload = await request.json()
|
|
auth = payload.get("auth") or {}
|
|
identity = auth.get("identity") or {}
|
|
methods = identity.get("methods") or []
|
|
if "password" not in methods:
|
|
raise OpenStackError(
|
|
"BadRequest", "Only password authentication is supported", status_code=400
|
|
)
|
|
password_block = (identity.get("password") or {}).get("user") or {}
|
|
user_name = password_block.get("name")
|
|
password = password_block.get("password")
|
|
domain_name = ((password_block.get("domain") or {}).get("name")) or "Default"
|
|
if not user_name or password is None:
|
|
raise OpenStackError("BadRequest", "user name and password are required", status_code=400)
|
|
|
|
scope = auth.get("scope") or {}
|
|
project_name = None
|
|
if "project" in scope:
|
|
project_name = (scope["project"] or {}).get("name")
|
|
if not project_name and (scope["project"] or {}).get("id"):
|
|
# resolve by id later via SQL
|
|
project_name = None
|
|
project_id = scope["project"]["id"]
|
|
else:
|
|
project_id = None
|
|
else:
|
|
project_id = None
|
|
|
|
host = request_public_host(request)
|
|
scheme = request_scheme(request)
|
|
|
|
async with pool.acquire() as conn:
|
|
if project_id and not project_name:
|
|
row = await conn.fetchrow(
|
|
"SELECT name FROM os_projects WHERE id = $1::uuid", project_id
|
|
)
|
|
if row is None:
|
|
raise OpenStackError("Unauthorized", "Project not found", status_code=401)
|
|
project_name = str(row["name"])
|
|
|
|
token_id, body = await issue_token(
|
|
conn,
|
|
user_name=str(user_name),
|
|
password=str(password),
|
|
project_name=str(project_name) if project_name else None,
|
|
domain_name=str(domain_name),
|
|
host=host,
|
|
scheme=scheme,
|
|
)
|
|
return JSONResponse(status_code=201, content=body, headers={"X-Subject-Token": token_id})
|
|
|
|
|
|
@router.get("/v3/auth/tokens")
|
|
async def show_token(
|
|
request: Request,
|
|
pool: Annotated[Pool, Depends(get_pool)],
|
|
) -> Response:
|
|
subject = request.headers.get("X-Subject-Token") or extract_token(
|
|
{k: v for k, v in request.headers.items()}
|
|
)
|
|
if not subject:
|
|
raise OpenStackError("Unauthorized", "X-Subject-Token required", status_code=401)
|
|
# Also require caller token in normal Keystone, but lab accepts subject alone or auth token.
|
|
async with pool.acquire() as conn:
|
|
ctx = await validate_token(conn, subject)
|
|
domain = await conn.fetchrow(
|
|
"""SELECT d.id, d.name FROM os_domains d
|
|
JOIN os_users u ON u.domain_id = d.id WHERE u.id = $1""",
|
|
ctx.user_id,
|
|
)
|
|
host = request_public_host(request)
|
|
scheme = request_scheme(request)
|
|
body: dict[str, Any] = {
|
|
"token": {
|
|
"methods": ["password"],
|
|
"expires_at": ctx.expires_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
|
"user": {
|
|
"id": str(ctx.user_id),
|
|
"name": ctx.user_name,
|
|
"domain": {
|
|
"id": str(domain["id"]) if domain else "",
|
|
"name": str(domain["name"]) if domain else "Default",
|
|
},
|
|
},
|
|
"roles": [{"id": r, "name": r} for r in ctx.roles],
|
|
}
|
|
}
|
|
if ctx.project_id is not None:
|
|
body["token"]["project"] = {
|
|
"id": str(ctx.project_id),
|
|
"name": ctx.project_name,
|
|
"domain": {
|
|
"id": str(domain["id"]) if domain else "",
|
|
"name": str(domain["name"]) if domain else "Default",
|
|
},
|
|
}
|
|
body["token"]["catalog"] = await build_catalog_from_db(conn, host, scheme=scheme)
|
|
return JSONResponse(content=body, headers={"X-Subject-Token": subject})
|
|
|
|
|
|
@router.delete("/v3/auth/tokens", status_code=204)
|
|
async def revoke_token(
|
|
request: Request,
|
|
pool: Annotated[Pool, Depends(get_pool)],
|
|
) -> Response:
|
|
subject = request.headers.get("X-Subject-Token")
|
|
if not subject:
|
|
raise OpenStackError("BadRequest", "X-Subject-Token required", status_code=400)
|
|
async with pool.acquire() as conn:
|
|
await conn.execute("UPDATE os_tokens SET revoked = true WHERE id = $1", subject)
|
|
return Response(status_code=204)
|
|
|
|
|
|
@router.get("/v3/auth/catalog")
|
|
async def auth_catalog(
|
|
request: Request,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
if ctx.project_id is None:
|
|
raise OpenStackError("Forbidden", "Project-scoped token required", status_code=403)
|
|
catalog = await build_catalog_from_db(
|
|
conn,
|
|
request_public_host(request),
|
|
scheme=request_scheme(request),
|
|
)
|
|
return {"catalog": catalog}
|
|
|
|
|
|
def _project_body(row: Any) -> dict[str, object]:
|
|
return {
|
|
"id": str(row["id"]),
|
|
"name": row["name"],
|
|
"description": row["description"],
|
|
"enabled": row["enabled"],
|
|
"domain_id": str(row["domain_id"]),
|
|
"is_domain": False,
|
|
"parent_id": str(row["domain_id"]),
|
|
"links": {"self": f"/v3/projects/{row['id']}"},
|
|
}
|
|
|
|
|
|
def _user_body(row: Any) -> dict[str, object]:
|
|
return {
|
|
"id": str(row["id"]),
|
|
"name": row["name"],
|
|
"enabled": row["enabled"],
|
|
"domain_id": str(row["domain_id"]),
|
|
"links": {"self": f"/v3/users/{row['id']}"},
|
|
}
|
|
|
|
|
|
@router.get("/v3/projects")
|
|
async def list_projects(
|
|
request: Request,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
from app.openstack.paging import paginate_rows
|
|
|
|
if ctx.is_admin:
|
|
rows = list(
|
|
await conn.fetch(
|
|
"SELECT id, name, description, enabled, domain_id FROM os_projects ORDER BY name, id"
|
|
)
|
|
)
|
|
else:
|
|
rows = list(
|
|
await conn.fetch(
|
|
"""SELECT p.id, p.name, p.description, p.enabled, p.domain_id
|
|
FROM os_projects p
|
|
JOIN os_role_assignments a ON a.project_id = p.id
|
|
WHERE a.user_id = $1
|
|
ORDER BY p.name, p.id""",
|
|
ctx.user_id,
|
|
)
|
|
)
|
|
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
|
|
body: dict[str, object] = {
|
|
"projects": [_project_body(r) for r in page],
|
|
"links": {"next": None, "previous": None, "self": "/v3/projects"},
|
|
}
|
|
if links:
|
|
body["projects_links"] = links
|
|
return body
|
|
|
|
|
|
@router.get("/v3/projects/{project_id}")
|
|
async def show_project(
|
|
project_id: str,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
row = await conn.fetchrow(
|
|
"SELECT id, name, description, enabled, domain_id FROM os_projects WHERE id = $1::uuid",
|
|
project_id,
|
|
)
|
|
if row is None:
|
|
raise OpenStackError("NotFound", f"Could not find project: {project_id}", status_code=404)
|
|
if not ctx.is_admin:
|
|
allowed = await conn.fetchval(
|
|
"""SELECT 1 FROM os_role_assignments
|
|
WHERE user_id = $1 AND project_id = $2::uuid LIMIT 1""",
|
|
ctx.user_id,
|
|
project_id,
|
|
)
|
|
if not allowed and str(ctx.project_id or "") != project_id:
|
|
raise OpenStackError(
|
|
"Forbidden", "Not authorized to access this project", status_code=403
|
|
)
|
|
return {"project": _project_body(row)}
|
|
|
|
|
|
@router.get("/v3/users")
|
|
async def list_users(
|
|
request: Request,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
from app.openstack.paging import paginate_rows
|
|
|
|
if not ctx.is_admin:
|
|
rows = list(
|
|
await conn.fetch(
|
|
"SELECT id, name, enabled, domain_id FROM os_users WHERE id = $1",
|
|
ctx.user_id,
|
|
)
|
|
)
|
|
else:
|
|
rows = list(
|
|
await conn.fetch("SELECT id, name, enabled, domain_id FROM os_users ORDER BY name, id")
|
|
)
|
|
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
|
|
body: dict[str, object] = {"users": [_user_body(r) for r in page]}
|
|
if links:
|
|
body["users_links"] = links
|
|
return body
|
|
|
|
|
|
@router.get("/v3/users/{user_id}")
|
|
async def show_user(
|
|
user_id: str,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
if not ctx.is_admin and str(ctx.user_id) != user_id:
|
|
raise OpenStackError("Forbidden", "Not authorized to access this user", status_code=403)
|
|
row = await conn.fetchrow(
|
|
"SELECT id, name, enabled, domain_id FROM os_users WHERE id = $1::uuid",
|
|
user_id,
|
|
)
|
|
if row is None:
|
|
raise OpenStackError("NotFound", f"Could not find user: {user_id}", status_code=404)
|
|
return {"user": _user_body(row)}
|
|
|
|
|
|
@router.get("/v3/domains")
|
|
async def list_domains(
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
rows = await conn.fetch(
|
|
"SELECT id, name, description, enabled FROM os_domains ORDER BY name, id"
|
|
)
|
|
return {
|
|
"domains": [
|
|
{
|
|
"id": str(r["id"]),
|
|
"name": r["name"],
|
|
"description": r["description"],
|
|
"enabled": r["enabled"],
|
|
"links": {"self": f"/v3/domains/{r['id']}"},
|
|
}
|
|
for r in rows
|
|
]
|
|
}
|
|
|
|
|
|
@router.get("/v3/domains/{domain_id}")
|
|
async def show_domain(
|
|
domain_id: str,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
row = await conn.fetchrow(
|
|
"""SELECT id, name, description, enabled FROM os_domains
|
|
WHERE id::text = $1 OR name = $1""",
|
|
domain_id,
|
|
)
|
|
if row is None:
|
|
raise OpenStackError("NotFound", f"Could not find domain: {domain_id}", status_code=404)
|
|
return {
|
|
"domain": {
|
|
"id": str(row["id"]),
|
|
"name": row["name"],
|
|
"description": row["description"],
|
|
"enabled": row["enabled"],
|
|
"links": {"self": f"/v3/domains/{row['id']}"},
|
|
}
|
|
}
|
|
|
|
|
|
@router.get("/v3/roles")
|
|
async def list_roles(
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
rows = await conn.fetch("SELECT id, name FROM os_roles ORDER BY name")
|
|
return {"roles": [{"id": str(r["id"]), "name": r["name"]} for r in rows]}
|
|
|
|
|
|
@router.get("/v3/roles/{role_id}")
|
|
async def show_role(
|
|
role_id: str,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
row = await conn.fetchrow(
|
|
"SELECT id, name FROM os_roles WHERE id::text = $1 OR name = $1",
|
|
role_id,
|
|
)
|
|
if row is None:
|
|
raise OpenStackError("NotFound", f"Could not find role: {role_id}", status_code=404)
|
|
return {"role": {"id": str(row["id"]), "name": row["name"]}}
|
|
|
|
|
|
@router.post("/v3/projects", status_code=201)
|
|
async def create_project(
|
|
request: Request,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
from uuid import uuid4
|
|
|
|
payload = (await request.json()).get("project") or {}
|
|
domain_id = payload.get("domain_id") or await conn.fetchval(
|
|
"SELECT id FROM os_domains ORDER BY name LIMIT 1"
|
|
)
|
|
row = await conn.fetchrow(
|
|
"""INSERT INTO os_projects(id, domain_id, name, description, enabled)
|
|
VALUES($1,$2,$3,$4,$5) RETURNING id, name, description, enabled, domain_id""",
|
|
uuid4(),
|
|
domain_id,
|
|
str(payload.get("name") or f"project-{uuid4().hex[:8]}"),
|
|
payload.get("description") or "",
|
|
bool(payload.get("enabled", True)),
|
|
)
|
|
_ = ctx
|
|
return {"project": _project_body(row)}
|
|
|
|
|
|
@router.post("/v3/users", status_code=201)
|
|
async def create_user(
|
|
request: Request,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
from uuid import uuid4
|
|
|
|
from app.security.auth import hash_secret
|
|
|
|
payload = (await request.json()).get("user") or {}
|
|
domain_id = payload.get("domain_id") or await conn.fetchval(
|
|
"SELECT id FROM os_domains ORDER BY name LIMIT 1"
|
|
)
|
|
password = str(payload.get("password") or "secret")
|
|
row = await conn.fetchrow(
|
|
"""INSERT INTO os_users(id, domain_id, name, password_hash, enabled)
|
|
VALUES($1,$2,$3,$4,$5) RETURNING id, name, enabled, domain_id""",
|
|
uuid4(),
|
|
domain_id,
|
|
str(payload.get("name") or f"user-{uuid4().hex[:8]}"),
|
|
hash_secret(password, salt=b"openstack-sim-v1"),
|
|
bool(payload.get("enabled", True)),
|
|
)
|
|
_ = ctx
|
|
return {"user": _user_body(row)}
|
|
|
|
|
|
@router.post("/v3/domains", status_code=201)
|
|
async def create_domain(
|
|
request: Request,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
from uuid import uuid4
|
|
|
|
payload = (await request.json()).get("domain") or {}
|
|
row = await conn.fetchrow(
|
|
"""INSERT INTO os_domains(id, name, description, enabled)
|
|
VALUES($1,$2,$3,$4) RETURNING id, name, description, enabled""",
|
|
uuid4(),
|
|
str(payload.get("name") or f"domain-{uuid4().hex[:8]}"),
|
|
payload.get("description") or "",
|
|
bool(payload.get("enabled", True)),
|
|
)
|
|
_ = ctx
|
|
return {
|
|
"domain": {
|
|
"id": str(row["id"]),
|
|
"name": row["name"],
|
|
"description": row["description"],
|
|
"enabled": row["enabled"],
|
|
"links": {"self": f"/v3/domains/{row['id']}"},
|
|
}
|
|
}
|
|
|
|
|
|
@router.post("/v3/roles", status_code=201)
|
|
async def create_role(
|
|
request: Request,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
from uuid import uuid4
|
|
|
|
payload = (await request.json()).get("role") or {}
|
|
row = await conn.fetchrow(
|
|
"""INSERT INTO os_roles(id, name) VALUES($1,$2) RETURNING id, name""",
|
|
uuid4(),
|
|
str(payload.get("name") or f"role-{uuid4().hex[:8]}"),
|
|
)
|
|
_ = ctx
|
|
return {"role": {"id": str(row["id"]), "name": row["name"]}}
|