generated from coulomb/repo-seed
Add small SaaS profile proof
This commit is contained in:
@@ -11,7 +11,9 @@ from .service import (
|
||||
list_models,
|
||||
list_standards,
|
||||
list_views,
|
||||
profile_graph,
|
||||
profile_inspect,
|
||||
profile_validate,
|
||||
read_view,
|
||||
validate_canon,
|
||||
write_validation_report,
|
||||
@@ -28,7 +30,9 @@ __all__ = [
|
||||
"list_models",
|
||||
"list_standards",
|
||||
"list_views",
|
||||
"profile_graph",
|
||||
"profile_inspect",
|
||||
"profile_validate",
|
||||
"read_view",
|
||||
"validate_canon",
|
||||
"write_validation_report",
|
||||
|
||||
@@ -15,7 +15,9 @@ from .service import (
|
||||
list_models,
|
||||
list_standards,
|
||||
list_views,
|
||||
profile_graph,
|
||||
profile_inspect,
|
||||
profile_validate,
|
||||
read_view,
|
||||
validate_canon,
|
||||
)
|
||||
@@ -96,6 +98,14 @@ def _route(
|
||||
if path.startswith("/profiles/") and path.endswith("/inspect"):
|
||||
profile = path.removeprefix("/profiles/").removesuffix("/inspect").strip("/")
|
||||
return HTTPStatus.OK, profile_inspect(profile, root)
|
||||
if path.startswith("/profiles/") and path.endswith("/validate"):
|
||||
profile = path.removeprefix("/profiles/").removesuffix("/validate").strip("/")
|
||||
payload = profile_validate(profile, root)
|
||||
return (HTTPStatus.OK if payload["ok"] else HTTPStatus.BAD_REQUEST), payload
|
||||
if path.startswith("/profiles/") and path.endswith("/graph"):
|
||||
profile = path.removeprefix("/profiles/").removesuffix("/graph").strip("/")
|
||||
graph_format = _first(query, "format") or "json"
|
||||
return HTTPStatus.OK, profile_graph(profile, root, output_format=graph_format)
|
||||
return HTTPStatus.NOT_FOUND, {
|
||||
"ok": False,
|
||||
"error": {
|
||||
|
||||
@@ -19,7 +19,9 @@ from .service import (
|
||||
list_models,
|
||||
list_standards,
|
||||
list_views,
|
||||
profile_graph,
|
||||
profile_inspect,
|
||||
profile_validate,
|
||||
read_view,
|
||||
validate_canon,
|
||||
write_validation_report,
|
||||
@@ -81,6 +83,13 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
profile_inspect_cmd = profile_sub.add_parser("inspect", help="Inspect a profile")
|
||||
profile_inspect_cmd.add_argument("profile")
|
||||
profile_inspect_cmd.set_defaults(handler=_profile_inspect)
|
||||
profile_validate_cmd = profile_sub.add_parser("validate", help="Validate a profile")
|
||||
profile_validate_cmd.add_argument("profile")
|
||||
profile_validate_cmd.set_defaults(handler=_profile_validate)
|
||||
profile_graph_cmd = profile_sub.add_parser("graph", help="Export a profile graph")
|
||||
profile_graph_cmd.add_argument("profile")
|
||||
profile_graph_cmd.add_argument("--format", choices=["json", "mermaid"], default="json")
|
||||
profile_graph_cmd.set_defaults(handler=_profile_graph)
|
||||
|
||||
api = sub.add_parser("api", help="Run the read-only local API")
|
||||
api.add_argument("--host", default="127.0.0.1")
|
||||
@@ -168,6 +177,14 @@ def _profile_inspect(args: argparse.Namespace) -> dict[str, Any]:
|
||||
return profile_inspect(args.profile, _root(args))
|
||||
|
||||
|
||||
def _profile_validate(args: argparse.Namespace) -> dict[str, Any]:
|
||||
return profile_validate(args.profile, _root(args))
|
||||
|
||||
|
||||
def _profile_graph(args: argparse.Namespace) -> dict[str, Any]:
|
||||
return profile_graph(args.profile, _root(args), output_format=args.format)
|
||||
|
||||
|
||||
def _api(args: argparse.Namespace) -> dict[str, Any]:
|
||||
serve(host=args.host, port=args.port, root=_root(args))
|
||||
return {}
|
||||
|
||||
402
src/info_tech_canon/profiles.py
Normal file
402
src/info_tech_canon/profiles.py
Normal file
@@ -0,0 +1,402 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from .bench import export_mermaid, relationship_summary
|
||||
|
||||
|
||||
REQUIRED_SMALL_SAAS_KINDS = {
|
||||
"service",
|
||||
"system",
|
||||
"tenant",
|
||||
"user",
|
||||
"team",
|
||||
"dataset",
|
||||
"deployment",
|
||||
"task",
|
||||
"policy",
|
||||
"control",
|
||||
"evidence",
|
||||
"incident",
|
||||
}
|
||||
|
||||
|
||||
def inspect_profile(context: Any, profile: str) -> dict[str, Any]:
|
||||
definition = load_profile_definition(context, profile)
|
||||
records = profile_artifact_records(context, profile)
|
||||
return {
|
||||
"ok": True,
|
||||
"profile": definition,
|
||||
"path": str(profile_path(context, profile)),
|
||||
"artifact_count": len(records),
|
||||
"artifacts": [artifact.to_dict() for artifact in records],
|
||||
}
|
||||
|
||||
|
||||
def validate_profile(context: Any, profile: str) -> dict[str, Any]:
|
||||
definition = load_profile_definition(context, profile)
|
||||
records = profile_artifact_records(context, profile)
|
||||
payloads = load_profile_artifacts(context, records)
|
||||
errors: list[dict[str, Any]] = []
|
||||
warnings: list[dict[str, Any]] = []
|
||||
|
||||
_check_profile_definition(profile, definition, records, errors)
|
||||
_check_required_artifact_kinds(profile, payloads, errors)
|
||||
_check_artifact_payloads(profile, payloads, errors)
|
||||
_check_service_ownership(payloads, errors)
|
||||
_check_tenant_namespace_separation(payloads, errors)
|
||||
_check_user_management_and_access(payloads, errors)
|
||||
_check_governance_evidence(payloads, errors)
|
||||
|
||||
return {
|
||||
"ok": not errors,
|
||||
"profile": profile,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"details": {
|
||||
"artifact_count": len(records),
|
||||
"payload_count": len(payloads),
|
||||
"kinds": _kind_counts(payloads),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def profile_graph(
|
||||
context: Any,
|
||||
profile: str,
|
||||
*,
|
||||
output_format: str = "json",
|
||||
) -> dict[str, Any]:
|
||||
records = profile_artifact_records(context, profile)
|
||||
record_ids = {artifact.id for artifact in records}
|
||||
include_ids = set(record_ids)
|
||||
for artifact in records:
|
||||
for relationship in artifact.relationships:
|
||||
target = relationship.get("target")
|
||||
if isinstance(target, str):
|
||||
include_ids.add(target)
|
||||
artifacts = [
|
||||
artifact for artifact in context.infospace.artifacts if artifact.id in include_ids
|
||||
]
|
||||
summary = relationship_summary(artifacts)
|
||||
if output_format == "mermaid":
|
||||
return {"ok": True, "profile": profile, "format": "mermaid", "graph": export_mermaid(summary)}
|
||||
if output_format != "json":
|
||||
raise ValueError(f"Unsupported graph format: {output_format}")
|
||||
return {
|
||||
"ok": True,
|
||||
"profile": profile,
|
||||
"format": "json",
|
||||
"graph": {
|
||||
"node_count": summary.node_count,
|
||||
"edge_count": summary.edge_count,
|
||||
"nodes": summary.nodes,
|
||||
"edges": [asdict(edge) for edge in summary.edges],
|
||||
"relationship_types": summary.relationship_types,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def profile_path(context: Any, profile: str) -> Path:
|
||||
return context.infospace_root / "profiles" / profile / "profile.yaml"
|
||||
|
||||
|
||||
def load_profile_definition(context: Any, profile: str) -> dict[str, Any]:
|
||||
path = profile_path(context, profile)
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
data = yaml.safe_load(handle) or {}
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Profile must be a YAML mapping: {profile}")
|
||||
return data
|
||||
|
||||
|
||||
def profile_artifact_records(context: Any, profile: str) -> list[Any]:
|
||||
return [
|
||||
artifact
|
||||
for artifact in context.infospace.artifacts
|
||||
if artifact.id == f"profile/{profile}"
|
||||
or artifact.provenance.get("profile") == profile
|
||||
]
|
||||
|
||||
|
||||
def load_profile_artifacts(context: Any, records: list[Any]) -> dict[str, dict[str, Any]]:
|
||||
payloads: dict[str, dict[str, Any]] = {}
|
||||
for artifact in records:
|
||||
path = context.infospace_root / artifact.path
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
data = yaml.safe_load(handle) or {}
|
||||
if isinstance(data, dict):
|
||||
payloads[artifact.id] = data
|
||||
return payloads
|
||||
|
||||
|
||||
def _check_profile_definition(
|
||||
profile: str,
|
||||
definition: dict[str, Any],
|
||||
records: list[Any],
|
||||
errors: list[dict[str, Any]],
|
||||
) -> None:
|
||||
for field in ("id", "title", "scope", "conformance_level", "required_standards"):
|
||||
if not definition.get(field):
|
||||
errors.append(
|
||||
{
|
||||
"code": "missing_profile_field",
|
||||
"profile": profile,
|
||||
"field": field,
|
||||
}
|
||||
)
|
||||
declared = set(definition.get("artifact_ids") or [])
|
||||
actual = {artifact.id for artifact in records}
|
||||
missing = sorted(declared - actual)
|
||||
for artifact_id in missing:
|
||||
errors.append(
|
||||
{
|
||||
"code": "missing_profile_artifact_record",
|
||||
"profile": profile,
|
||||
"artifact_id": artifact_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _check_required_artifact_kinds(
|
||||
profile: str,
|
||||
payloads: dict[str, dict[str, Any]],
|
||||
errors: list[dict[str, Any]],
|
||||
) -> None:
|
||||
kinds = {payload.get("kind") for payload in payloads.values()}
|
||||
for kind in sorted(REQUIRED_SMALL_SAAS_KINDS - kinds):
|
||||
errors.append(
|
||||
{
|
||||
"code": "missing_required_profile_kind",
|
||||
"profile": profile,
|
||||
"kind": kind,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _check_artifact_payloads(
|
||||
profile: str,
|
||||
payloads: dict[str, dict[str, Any]],
|
||||
errors: list[dict[str, Any]],
|
||||
) -> None:
|
||||
ids = set(payloads)
|
||||
for artifact_id, payload in payloads.items():
|
||||
for field in ("id", "kind", "title", "profile"):
|
||||
if not payload.get(field):
|
||||
errors.append(
|
||||
{
|
||||
"code": "missing_profile_artifact_field",
|
||||
"profile": profile,
|
||||
"artifact_id": artifact_id,
|
||||
"field": field,
|
||||
}
|
||||
)
|
||||
if payload.get("profile") not in {profile, None}:
|
||||
errors.append(
|
||||
{
|
||||
"code": "profile_artifact_profile_mismatch",
|
||||
"profile": profile,
|
||||
"artifact_id": artifact_id,
|
||||
"value": payload.get("profile"),
|
||||
}
|
||||
)
|
||||
for relationship in payload.get("relationships") or []:
|
||||
target = relationship.get("target")
|
||||
if target and target not in ids:
|
||||
errors.append(
|
||||
{
|
||||
"code": "profile_relationship_target_not_in_payloads",
|
||||
"profile": profile,
|
||||
"artifact_id": artifact_id,
|
||||
"target": target,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _check_service_ownership(
|
||||
payloads: dict[str, dict[str, Any]],
|
||||
errors: list[dict[str, Any]],
|
||||
) -> None:
|
||||
service = _one_kind(payloads, "service")
|
||||
if not service:
|
||||
return
|
||||
owner_team = service.get("owner_team")
|
||||
if not _exists_kind(payloads, owner_team, "team"):
|
||||
errors.append(
|
||||
{
|
||||
"code": "invalid_service_owner_team",
|
||||
"artifact_id": service.get("id"),
|
||||
"owner_team": owner_team,
|
||||
}
|
||||
)
|
||||
team = payloads.get(str(owner_team))
|
||||
if team and not _exists_kind(payloads, team.get("owner_user"), "user"):
|
||||
errors.append(
|
||||
{
|
||||
"code": "invalid_team_owner_user",
|
||||
"artifact_id": team.get("id"),
|
||||
"owner_user": team.get("owner_user"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _check_tenant_namespace_separation(
|
||||
payloads: dict[str, dict[str, Any]],
|
||||
errors: list[dict[str, Any]],
|
||||
) -> None:
|
||||
tenants = [payload for payload in payloads.values() if payload.get("kind") == "tenant"]
|
||||
namespaces = [tenant.get("namespace") for tenant in tenants]
|
||||
if len(namespaces) != len(set(namespaces)):
|
||||
errors.append({"code": "duplicate_tenant_namespace"})
|
||||
deployment = _one_kind(payloads, "deployment")
|
||||
if deployment:
|
||||
if deployment.get("namespace_strategy") != "namespace-per-tenant":
|
||||
errors.append(
|
||||
{
|
||||
"code": "invalid_namespace_strategy",
|
||||
"artifact_id": deployment.get("id"),
|
||||
"namespace_strategy": deployment.get("namespace_strategy"),
|
||||
}
|
||||
)
|
||||
tenant_namespaces = deployment.get("tenant_namespaces") or {}
|
||||
for tenant in tenants:
|
||||
if tenant.get("id") not in tenant_namespaces:
|
||||
errors.append(
|
||||
{
|
||||
"code": "tenant_missing_deployment_namespace",
|
||||
"tenant_id": tenant.get("id"),
|
||||
"deployment_id": deployment.get("id"),
|
||||
}
|
||||
)
|
||||
dataset = _one_kind(payloads, "dataset")
|
||||
if dataset:
|
||||
if dataset.get("tenant_scope") != "per-tenant":
|
||||
errors.append(
|
||||
{
|
||||
"code": "dataset_not_per_tenant",
|
||||
"artifact_id": dataset.get("id"),
|
||||
}
|
||||
)
|
||||
tenant_ids = set(dataset.get("tenant_ids") or [])
|
||||
for tenant in tenants:
|
||||
if tenant.get("id") not in tenant_ids:
|
||||
errors.append(
|
||||
{
|
||||
"code": "tenant_missing_dataset_partition",
|
||||
"tenant_id": tenant.get("id"),
|
||||
"dataset_id": dataset.get("id"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _check_user_management_and_access(
|
||||
payloads: dict[str, dict[str, Any]],
|
||||
errors: list[dict[str, Any]],
|
||||
) -> None:
|
||||
evidence_ids = _ids_by_kind(payloads, "evidence")
|
||||
policy_ids = _ids_by_kind(payloads, "policy")
|
||||
tenant_ids = _ids_by_kind(payloads, "tenant")
|
||||
for user in [payload for payload in payloads.values() if payload.get("kind") == "user"]:
|
||||
if not user.get("teams"):
|
||||
errors.append(
|
||||
{
|
||||
"code": "user_missing_team_membership",
|
||||
"artifact_id": user.get("id"),
|
||||
}
|
||||
)
|
||||
grants = user.get("access_grants") or []
|
||||
if not grants:
|
||||
errors.append(
|
||||
{
|
||||
"code": "user_missing_access_grant",
|
||||
"artifact_id": user.get("id"),
|
||||
}
|
||||
)
|
||||
for grant in grants:
|
||||
if grant.get("tenant_id") not in tenant_ids:
|
||||
errors.append(
|
||||
{
|
||||
"code": "access_grant_missing_tenant",
|
||||
"artifact_id": user.get("id"),
|
||||
"tenant_id": grant.get("tenant_id"),
|
||||
}
|
||||
)
|
||||
if grant.get("policy_id") not in policy_ids:
|
||||
errors.append(
|
||||
{
|
||||
"code": "access_grant_missing_policy",
|
||||
"artifact_id": user.get("id"),
|
||||
"policy_id": grant.get("policy_id"),
|
||||
}
|
||||
)
|
||||
if grant.get("evidence_id") not in evidence_ids:
|
||||
errors.append(
|
||||
{
|
||||
"code": "access_grant_missing_evidence",
|
||||
"artifact_id": user.get("id"),
|
||||
"evidence_id": grant.get("evidence_id"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _check_governance_evidence(
|
||||
payloads: dict[str, dict[str, Any]],
|
||||
errors: list[dict[str, Any]],
|
||||
) -> None:
|
||||
evidence_ids = _ids_by_kind(payloads, "evidence")
|
||||
for kind in ("policy", "control", "incident", "service", "dataset", "deployment", "task"):
|
||||
for payload in [item for item in payloads.values() if item.get("kind") == kind]:
|
||||
ids = set(payload.get("evidence_ids") or [])
|
||||
if not ids:
|
||||
errors.append(
|
||||
{
|
||||
"code": "artifact_missing_evidence",
|
||||
"artifact_id": payload.get("id"),
|
||||
"kind": kind,
|
||||
}
|
||||
)
|
||||
for evidence_id in ids:
|
||||
if evidence_id not in evidence_ids:
|
||||
errors.append(
|
||||
{
|
||||
"code": "artifact_missing_evidence_target",
|
||||
"artifact_id": payload.get("id"),
|
||||
"evidence_id": evidence_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _kind_counts(payloads: dict[str, dict[str, Any]]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for payload in payloads.values():
|
||||
kind = str(payload.get("kind") or "unknown")
|
||||
counts[kind] = counts.get(kind, 0) + 1
|
||||
return dict(sorted(counts.items()))
|
||||
|
||||
|
||||
def _one_kind(payloads: dict[str, dict[str, Any]], kind: str) -> dict[str, Any] | None:
|
||||
for payload in payloads.values():
|
||||
if payload.get("kind") == kind:
|
||||
return payload
|
||||
return None
|
||||
|
||||
|
||||
def _exists_kind(
|
||||
payloads: dict[str, dict[str, Any]],
|
||||
artifact_id: Any,
|
||||
kind: str,
|
||||
) -> bool:
|
||||
payload = payloads.get(str(artifact_id))
|
||||
return bool(payload and payload.get("kind") == kind)
|
||||
|
||||
|
||||
def _ids_by_kind(payloads: dict[str, dict[str, Any]], kind: str) -> set[str]:
|
||||
return {
|
||||
str(payload["id"])
|
||||
for payload in payloads.values()
|
||||
if payload.get("kind") == kind and payload.get("id")
|
||||
}
|
||||
@@ -6,9 +6,8 @@ from pathlib import Path
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from . import generation
|
||||
from . import profiles
|
||||
from .bench import (
|
||||
Infospace,
|
||||
KnowledgeArtifact,
|
||||
@@ -230,15 +229,53 @@ def profile_inspect(
|
||||
f"Profile not found: {profile}",
|
||||
{"profile": profile, "path": str(profile_path)},
|
||||
)
|
||||
with profile_path.open("r", encoding="utf-8") as handle:
|
||||
data = yaml.safe_load(handle) or {}
|
||||
if not isinstance(data, dict):
|
||||
try:
|
||||
return profiles.inspect_profile(context, profile)
|
||||
except ValueError as exc:
|
||||
raise CanonServiceError(
|
||||
"invalid_profile",
|
||||
f"Profile must be a YAML mapping: {profile}",
|
||||
{"profile": profile, "path": str(profile_path)},
|
||||
) from exc
|
||||
|
||||
|
||||
def profile_validate(
|
||||
profile: str,
|
||||
root: Path | str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
context = load_context(root)
|
||||
profile_path = context.infospace_root / "profiles" / profile / "profile.yaml"
|
||||
if not profile_path.is_file():
|
||||
raise CanonServiceError(
|
||||
"missing_profile",
|
||||
f"Profile not found: {profile}",
|
||||
{"profile": profile, "path": str(profile_path)},
|
||||
)
|
||||
return {"ok": True, "profile": data, "path": str(profile_path)}
|
||||
return profiles.validate_profile(context, profile)
|
||||
|
||||
|
||||
def profile_graph(
|
||||
profile: str,
|
||||
root: Path | str | None = None,
|
||||
*,
|
||||
output_format: str = "json",
|
||||
) -> dict[str, Any]:
|
||||
context = load_context(root)
|
||||
profile_path = context.infospace_root / "profiles" / profile / "profile.yaml"
|
||||
if not profile_path.is_file():
|
||||
raise CanonServiceError(
|
||||
"missing_profile",
|
||||
f"Profile not found: {profile}",
|
||||
{"profile": profile, "path": str(profile_path)},
|
||||
)
|
||||
try:
|
||||
return profiles.profile_graph(context, profile, output_format=output_format)
|
||||
except ValueError as exc:
|
||||
raise CanonServiceError(
|
||||
"unsupported_graph_format",
|
||||
str(exc),
|
||||
{"supported": ["json", "mermaid"]},
|
||||
) from exc
|
||||
|
||||
|
||||
def generate_indexes(root: Path | str | None = None) -> dict[str, Any]:
|
||||
|
||||
Reference in New Issue
Block a user