sovaria-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apps/cli/README.md +147 -0
- apps/cli/__init__.py +0 -0
- apps/cli/src/__init__.py +3 -0
- apps/cli/src/commands/__init__.py +1 -0
- apps/cli/src/commands/admin.py +160 -0
- apps/cli/src/commands/api.py +48 -0
- apps/cli/src/commands/audit.py +181 -0
- apps/cli/src/commands/auth_product.py +187 -0
- apps/cli/src/commands/billing.py +513 -0
- apps/cli/src/commands/chrono.py +248 -0
- apps/cli/src/commands/context.py +99 -0
- apps/cli/src/commands/data.py +272 -0
- apps/cli/src/commands/db.py +528 -0
- apps/cli/src/commands/deploy.py +1099 -0
- apps/cli/src/commands/governance.py +491 -0
- apps/cli/src/commands/iam.py +436 -0
- apps/cli/src/commands/init_cmd.py +177 -0
- apps/cli/src/commands/keys.py +120 -0
- apps/cli/src/commands/login.py +842 -0
- apps/cli/src/commands/org.py +157 -0
- apps/cli/src/commands/policy.py +317 -0
- apps/cli/src/commands/project.py +130 -0
- apps/cli/src/commands/realtime.py +364 -0
- apps/cli/src/commands/scope.py +42 -0
- apps/cli/src/commands/shell.py +415 -0
- apps/cli/src/commands/sim.py +178 -0
- apps/cli/src/commands/storage.py +405 -0
- apps/cli/src/commands/vector.py +204 -0
- apps/cli/src/main.py +169 -0
- apps/cli/src/utils/__init__.py +1 -0
- apps/cli/src/utils/api_client.py +329 -0
- apps/cli/src/utils/auth_store.py +299 -0
- apps/cli/src/utils/config.py +185 -0
- apps/cli/src/utils/display.py +62 -0
- apps/cli/src/utils/migrations.py +69 -0
- apps/cli/src/utils/project_context.py +124 -0
- apps/cli/tests/test_auth_store_profiles.py +103 -0
- apps/cli/tests/test_cli_security.py +110 -0
- apps/cli/tests/test_cli_smoke_paths.py +279 -0
- apps/cli/tests/test_config_profiles.py +55 -0
- apps/cli/tests/test_main_startup.py +29 -0
- apps/cli/tests/test_project_and_commands.py +196 -0
- sovaria_cli-0.2.0.dist-info/METADATA +170 -0
- sovaria_cli-0.2.0.dist-info/RECORD +46 -0
- sovaria_cli-0.2.0.dist-info/WHEEL +4 -0
- sovaria_cli-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""
|
|
2
|
+
apps.cli.src.commands.org — Organisation and membership management commands.
|
|
3
|
+
|
|
4
|
+
Phase 1 §7 Sprint 3.2: The "day in the life" demo walkthrough requires:
|
|
5
|
+
sov org create "Acme Corp"
|
|
6
|
+
sov org member-add <org_id> <user_id> [--role ADMIN]
|
|
7
|
+
|
|
8
|
+
These commands proxy requests through the Gateway to the Axis org endpoints:
|
|
9
|
+
POST /api/v1/org — create any node (type=ORG, USER, ...)
|
|
10
|
+
POST /api/v1/org/{org_id}/member — add a MEMBER_OF edge
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
sov org create "Acme Corp"
|
|
14
|
+
sov org create "Acme Corp" --type ORG --profile staging
|
|
15
|
+
sov org member-add <org_id> <user_id>
|
|
16
|
+
sov org member-add <org_id> <user_id> --role ADMIN
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
import typer
|
|
25
|
+
|
|
26
|
+
from apps.cli.src.utils.auth_store import load_tokens
|
|
27
|
+
from apps.cli.src.utils.config import get_api_url
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(help="Organisation management commands")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command("create")
|
|
33
|
+
def create_org(
|
|
34
|
+
name: str = typer.Argument(..., help="Human-readable organisation name"),
|
|
35
|
+
node_type: str = typer.Option(
|
|
36
|
+
"ORG",
|
|
37
|
+
"--type",
|
|
38
|
+
"-t",
|
|
39
|
+
help="Node type: ORG | PROJECT | ENV",
|
|
40
|
+
),
|
|
41
|
+
metadata: str | None = typer.Option(
|
|
42
|
+
None,
|
|
43
|
+
"--metadata",
|
|
44
|
+
"-m",
|
|
45
|
+
help='Extra metadata as a JSON string, e.g. \'{"industry": "fintech"}\'',
|
|
46
|
+
),
|
|
47
|
+
profile: str | None = typer.Option(
|
|
48
|
+
None, "--profile", help="Config profile to use (dev/staging/prod)"
|
|
49
|
+
),
|
|
50
|
+
) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Create a new organisation (or PROJECT/ENV) node in the identity graph.
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
sov org create "Acme Corp"
|
|
56
|
+
sov org create "Payments" --type PROJECT --metadata '{"env": "prod"}'
|
|
57
|
+
"""
|
|
58
|
+
tokens = load_tokens(profile=profile)
|
|
59
|
+
if not tokens:
|
|
60
|
+
typer.echo("Not logged in. Run: sov login", err=True)
|
|
61
|
+
raise typer.Exit(1)
|
|
62
|
+
|
|
63
|
+
# Parse optional JSON metadata
|
|
64
|
+
meta: dict = {}
|
|
65
|
+
if metadata:
|
|
66
|
+
try:
|
|
67
|
+
meta = json.loads(metadata)
|
|
68
|
+
except json.JSONDecodeError as exc:
|
|
69
|
+
typer.echo(f"--metadata must be valid JSON: {exc}", err=True)
|
|
70
|
+
raise typer.Exit(1) from exc
|
|
71
|
+
|
|
72
|
+
api_url = get_api_url(profile)
|
|
73
|
+
headers = {
|
|
74
|
+
"Authorization": f"Bearer {tokens['access_token']}",
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
}
|
|
77
|
+
payload = {"type": node_type, "name": name, "metadata": meta}
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
resp = httpx.post(
|
|
81
|
+
f"{api_url}/api/v1/org",
|
|
82
|
+
headers=headers,
|
|
83
|
+
json=payload,
|
|
84
|
+
timeout=30,
|
|
85
|
+
)
|
|
86
|
+
resp.raise_for_status()
|
|
87
|
+
except httpx.HTTPStatusError as exc:
|
|
88
|
+
detail = exc.response.text
|
|
89
|
+
try:
|
|
90
|
+
detail = exc.response.json().get("detail", detail)
|
|
91
|
+
except Exception: # pylint: disable=broad-except
|
|
92
|
+
pass
|
|
93
|
+
typer.echo(f"Failed to create {node_type}: {detail}", err=True)
|
|
94
|
+
raise typer.Exit(1) from exc
|
|
95
|
+
|
|
96
|
+
data = resp.json()
|
|
97
|
+
node_id = data.get("node_id")
|
|
98
|
+
typer.echo(
|
|
99
|
+
typer.style("✓ Created", fg=typer.colors.GREEN)
|
|
100
|
+
+ f" {node_type} '{name}' node_id={node_id}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command("member-add")
|
|
105
|
+
def add_member(
|
|
106
|
+
org_id: str = typer.Argument(..., help="UUID of the ORG/PROJECT node"),
|
|
107
|
+
user_id: str = typer.Argument(..., help="UUID of the USER node to add"),
|
|
108
|
+
role: str = typer.Option(
|
|
109
|
+
"MEMBER",
|
|
110
|
+
"--role",
|
|
111
|
+
"-r",
|
|
112
|
+
help="Role to assign: ADMIN | MEMBER | VIEWER (default: MEMBER)",
|
|
113
|
+
),
|
|
114
|
+
profile: str | None = typer.Option(
|
|
115
|
+
None, "--profile", help="Config profile to use (dev/staging/prod)"
|
|
116
|
+
),
|
|
117
|
+
) -> None:
|
|
118
|
+
"""
|
|
119
|
+
Add a user as a member of an organisation (creates a MEMBER_OF edge).
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
sov org member-add <org_id> <user_id>
|
|
123
|
+
sov org member-add <org_id> <user_id> --role ADMIN
|
|
124
|
+
"""
|
|
125
|
+
tokens = load_tokens(profile=profile)
|
|
126
|
+
if not tokens:
|
|
127
|
+
typer.echo("Not logged in. Run: sov login", err=True)
|
|
128
|
+
raise typer.Exit(1)
|
|
129
|
+
|
|
130
|
+
api_url = get_api_url(profile)
|
|
131
|
+
headers = {
|
|
132
|
+
"Authorization": f"Bearer {tokens['access_token']}",
|
|
133
|
+
"Content-Type": "application/json",
|
|
134
|
+
}
|
|
135
|
+
payload = {"user_id": user_id, "role": role}
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
resp = httpx.post(
|
|
139
|
+
f"{api_url}/api/v1/org/{org_id}/member",
|
|
140
|
+
headers=headers,
|
|
141
|
+
json=payload,
|
|
142
|
+
timeout=30,
|
|
143
|
+
)
|
|
144
|
+
resp.raise_for_status()
|
|
145
|
+
except httpx.HTTPStatusError as exc:
|
|
146
|
+
detail = exc.response.text
|
|
147
|
+
try:
|
|
148
|
+
detail = exc.response.json().get("detail", detail)
|
|
149
|
+
except Exception: # pylint: disable=broad-except
|
|
150
|
+
pass
|
|
151
|
+
typer.echo(f"Failed to add member: {detail}", err=True)
|
|
152
|
+
raise typer.Exit(1) from exc
|
|
153
|
+
|
|
154
|
+
typer.echo(
|
|
155
|
+
typer.style("✓ Member added", fg=typer.colors.GREEN)
|
|
156
|
+
+ f" user={user_id} org={org_id} role={role}"
|
|
157
|
+
)
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Guard policy commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TypedDict
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from apps.cli.src.utils.api_client import CLIRequestError, call_api, parse_json_arg, print_json
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Guard policy engine commands")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ScopeOpts(TypedDict):
|
|
16
|
+
org_id: str | None
|
|
17
|
+
project_id: str | None
|
|
18
|
+
env_id: str | None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _scope_opts(org_id: str | None, project_id: str | None, env_id: str | None) -> ScopeOpts:
|
|
22
|
+
return {
|
|
23
|
+
"org_id": org_id,
|
|
24
|
+
"project_id": project_id,
|
|
25
|
+
"env_id": env_id,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_conflict_error(exc: CLIRequestError) -> bool:
|
|
30
|
+
status_code = getattr(exc, "status_code", None)
|
|
31
|
+
message = str(exc).lower()
|
|
32
|
+
return status_code == 409 or "already exists" in message
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command("templates")
|
|
36
|
+
def templates(
|
|
37
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
38
|
+
) -> None:
|
|
39
|
+
"""List built-in SPL policy templates."""
|
|
40
|
+
try:
|
|
41
|
+
result = call_api(
|
|
42
|
+
method="GET",
|
|
43
|
+
path="/api/v1/policies/templates",
|
|
44
|
+
profile=profile,
|
|
45
|
+
)
|
|
46
|
+
except CLIRequestError as exc:
|
|
47
|
+
typer.echo(str(exc), err=True)
|
|
48
|
+
raise typer.Exit(1) from exc
|
|
49
|
+
typer.echo(print_json(result))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command("list")
|
|
53
|
+
def list_policies(
|
|
54
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
55
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
56
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
57
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
58
|
+
) -> None:
|
|
59
|
+
"""List active policies in scope."""
|
|
60
|
+
try:
|
|
61
|
+
result = call_api(
|
|
62
|
+
method="GET",
|
|
63
|
+
path="/api/v1/policies/",
|
|
64
|
+
profile=profile,
|
|
65
|
+
require_scope=False,
|
|
66
|
+
**_scope_opts(org_id, project_id, env_id),
|
|
67
|
+
)
|
|
68
|
+
except CLIRequestError as exc:
|
|
69
|
+
typer.echo(str(exc), err=True)
|
|
70
|
+
raise typer.Exit(1) from exc
|
|
71
|
+
typer.echo(print_json(result))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command("get")
|
|
75
|
+
def get_policy(
|
|
76
|
+
policy_id: str = typer.Argument(...),
|
|
77
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
78
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
79
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
80
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Fetch a policy by ID."""
|
|
83
|
+
safe_id = quote(policy_id, safe="")
|
|
84
|
+
try:
|
|
85
|
+
result = call_api(
|
|
86
|
+
method="GET",
|
|
87
|
+
path=f"/api/v1/policies/{safe_id}",
|
|
88
|
+
profile=profile,
|
|
89
|
+
require_scope=False,
|
|
90
|
+
**_scope_opts(org_id, project_id, env_id),
|
|
91
|
+
)
|
|
92
|
+
except CLIRequestError as exc:
|
|
93
|
+
typer.echo(str(exc), err=True)
|
|
94
|
+
raise typer.Exit(1) from exc
|
|
95
|
+
typer.echo(print_json(result))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command("upsert")
|
|
99
|
+
def upsert_policy(
|
|
100
|
+
policy_id: str = typer.Argument(...),
|
|
101
|
+
name: str = typer.Option(..., "--name"),
|
|
102
|
+
spl_source: str = typer.Option(..., "--spl"),
|
|
103
|
+
org_id: str = typer.Option(..., "--org", help="Owning org UUID"),
|
|
104
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
105
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
106
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Create or update a policy (tries create then update)."""
|
|
109
|
+
try:
|
|
110
|
+
create_result = call_api(
|
|
111
|
+
method="POST",
|
|
112
|
+
path="/api/v1/policies/",
|
|
113
|
+
profile=profile,
|
|
114
|
+
body={
|
|
115
|
+
"policy_id": policy_id,
|
|
116
|
+
"name": name,
|
|
117
|
+
"spl_source": spl_source,
|
|
118
|
+
"org_id": org_id,
|
|
119
|
+
},
|
|
120
|
+
require_scope=False,
|
|
121
|
+
**_scope_opts(org_id, project_id, env_id),
|
|
122
|
+
)
|
|
123
|
+
typer.echo(print_json(create_result))
|
|
124
|
+
return
|
|
125
|
+
except CLIRequestError as exc:
|
|
126
|
+
if not _is_conflict_error(exc):
|
|
127
|
+
typer.echo(str(exc), err=True)
|
|
128
|
+
raise typer.Exit(1) from exc
|
|
129
|
+
|
|
130
|
+
safe_id = quote(policy_id, safe="")
|
|
131
|
+
try:
|
|
132
|
+
update_result = call_api(
|
|
133
|
+
method="PUT",
|
|
134
|
+
path=f"/api/v1/policies/{safe_id}",
|
|
135
|
+
profile=profile,
|
|
136
|
+
body={"name": name, "spl_source": spl_source},
|
|
137
|
+
require_scope=False,
|
|
138
|
+
**_scope_opts(org_id, project_id, env_id),
|
|
139
|
+
)
|
|
140
|
+
except CLIRequestError as exc:
|
|
141
|
+
typer.echo(str(exc), err=True)
|
|
142
|
+
raise typer.Exit(1) from exc
|
|
143
|
+
|
|
144
|
+
typer.echo(print_json(update_result))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@app.command("delete")
|
|
148
|
+
def delete_policy(
|
|
149
|
+
policy_id: str = typer.Argument(...),
|
|
150
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
151
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
152
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
153
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Delete (deactivate) a policy."""
|
|
156
|
+
safe_id = quote(policy_id, safe="")
|
|
157
|
+
try:
|
|
158
|
+
result = call_api(
|
|
159
|
+
method="DELETE",
|
|
160
|
+
path=f"/api/v1/policies/{safe_id}",
|
|
161
|
+
profile=profile,
|
|
162
|
+
require_scope=False,
|
|
163
|
+
**_scope_opts(org_id, project_id, env_id),
|
|
164
|
+
)
|
|
165
|
+
except CLIRequestError as exc:
|
|
166
|
+
typer.echo(str(exc), err=True)
|
|
167
|
+
raise typer.Exit(1) from exc
|
|
168
|
+
typer.echo(print_json(result))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@app.command("history")
|
|
172
|
+
def policy_history(
|
|
173
|
+
policy_id: str = typer.Argument(...),
|
|
174
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
175
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
176
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
177
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
178
|
+
) -> None:
|
|
179
|
+
"""List policy version history."""
|
|
180
|
+
safe_id = quote(policy_id, safe="")
|
|
181
|
+
try:
|
|
182
|
+
result = call_api(
|
|
183
|
+
method="GET",
|
|
184
|
+
path=f"/api/v1/policies/{safe_id}/history",
|
|
185
|
+
profile=profile,
|
|
186
|
+
require_scope=False,
|
|
187
|
+
**_scope_opts(org_id, project_id, env_id),
|
|
188
|
+
)
|
|
189
|
+
except CLIRequestError as exc:
|
|
190
|
+
typer.echo(str(exc), err=True)
|
|
191
|
+
raise typer.Exit(1) from exc
|
|
192
|
+
typer.echo(print_json(result))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@app.command("rollback")
|
|
196
|
+
def rollback_policy(
|
|
197
|
+
policy_id: str = typer.Argument(...),
|
|
198
|
+
version: int = typer.Option(..., "--version"),
|
|
199
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
200
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
201
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
202
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Rollback policy to a historical version."""
|
|
205
|
+
safe_id = quote(policy_id, safe="")
|
|
206
|
+
try:
|
|
207
|
+
result = call_api(
|
|
208
|
+
method="POST",
|
|
209
|
+
path=f"/api/v1/policies/{safe_id}/rollback",
|
|
210
|
+
profile=profile,
|
|
211
|
+
body={"version": version},
|
|
212
|
+
require_scope=False,
|
|
213
|
+
**_scope_opts(org_id, project_id, env_id),
|
|
214
|
+
)
|
|
215
|
+
except CLIRequestError as exc:
|
|
216
|
+
typer.echo(str(exc), err=True)
|
|
217
|
+
raise typer.Exit(1) from exc
|
|
218
|
+
typer.echo(print_json(result))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@app.command("evaluate")
|
|
222
|
+
def evaluate_policy(
|
|
223
|
+
policy_id: str = typer.Argument(...),
|
|
224
|
+
context: str = typer.Option(..., "--context", help="JSON object"),
|
|
225
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
226
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
227
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
228
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
229
|
+
) -> None:
|
|
230
|
+
"""Evaluate policy against an explicit context."""
|
|
231
|
+
safe_id = quote(policy_id, safe="")
|
|
232
|
+
try:
|
|
233
|
+
parsed_context = parse_json_arg(context, label="--context")
|
|
234
|
+
if not isinstance(parsed_context, dict):
|
|
235
|
+
raise CLIRequestError("--context must be a JSON object")
|
|
236
|
+
|
|
237
|
+
result = call_api(
|
|
238
|
+
method="POST",
|
|
239
|
+
path=f"/api/v1/policies/{safe_id}/evaluate",
|
|
240
|
+
profile=profile,
|
|
241
|
+
body={"context": parsed_context},
|
|
242
|
+
require_scope=False,
|
|
243
|
+
**_scope_opts(org_id, project_id, env_id),
|
|
244
|
+
)
|
|
245
|
+
except CLIRequestError as exc:
|
|
246
|
+
typer.echo(str(exc), err=True)
|
|
247
|
+
raise typer.Exit(1) from exc
|
|
248
|
+
|
|
249
|
+
typer.echo(print_json(result))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@app.command("check")
|
|
253
|
+
def check_policy(
|
|
254
|
+
action: str = typer.Option(..., "--action", help="Action, e.g. sql:select or nosql:write"),
|
|
255
|
+
resource: str = typer.Option(..., "--resource", help="Resource name, table, or collection"),
|
|
256
|
+
context: str | None = typer.Option(None, "--context", help="Optional JSON context object"),
|
|
257
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
258
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
259
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
260
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Preflight an action/resource against active Guard policies."""
|
|
263
|
+
try:
|
|
264
|
+
parsed_context = parse_json_arg(context, label="--context") if context else {}
|
|
265
|
+
if not isinstance(parsed_context, dict):
|
|
266
|
+
raise CLIRequestError("--context must be a JSON object")
|
|
267
|
+
if not org_id or not project_id:
|
|
268
|
+
raise CLIRequestError("--org and --project are required for policy check")
|
|
269
|
+
|
|
270
|
+
result = call_api(
|
|
271
|
+
method="POST",
|
|
272
|
+
path="/api/v1/policies/check",
|
|
273
|
+
profile=profile,
|
|
274
|
+
body={
|
|
275
|
+
"org_id": org_id,
|
|
276
|
+
"project_id": project_id,
|
|
277
|
+
"action": action,
|
|
278
|
+
"resource": resource,
|
|
279
|
+
"context": parsed_context,
|
|
280
|
+
},
|
|
281
|
+
require_scope=False,
|
|
282
|
+
**_scope_opts(org_id, project_id, env_id),
|
|
283
|
+
)
|
|
284
|
+
except CLIRequestError as exc:
|
|
285
|
+
typer.echo(str(exc), err=True)
|
|
286
|
+
raise typer.Exit(1) from exc
|
|
287
|
+
|
|
288
|
+
typer.echo(print_json(result))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@app.command("simulate")
|
|
292
|
+
def simulate_policy(
|
|
293
|
+
spl_source: str = typer.Option(..., "--spl", help="SPL policy source to dry-run"),
|
|
294
|
+
context: str = typer.Option("{}", "--context", help="JSON evaluation context"),
|
|
295
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
296
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
297
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
298
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
299
|
+
) -> None:
|
|
300
|
+
"""Simulate SPL policy evaluation without persisting a policy."""
|
|
301
|
+
try:
|
|
302
|
+
parsed_context = parse_json_arg(context, label="--context")
|
|
303
|
+
if parsed_context is None or not isinstance(parsed_context, dict):
|
|
304
|
+
raise CLIRequestError("--context must be a JSON object")
|
|
305
|
+
result = call_api(
|
|
306
|
+
method="POST",
|
|
307
|
+
path="/api/v1/policies/simulate",
|
|
308
|
+
profile=profile,
|
|
309
|
+
body={"spl_source": spl_source, "context": parsed_context},
|
|
310
|
+
require_scope=True,
|
|
311
|
+
**_scope_opts(org_id, project_id, env_id),
|
|
312
|
+
)
|
|
313
|
+
except CLIRequestError as exc:
|
|
314
|
+
typer.echo(str(exc), err=True)
|
|
315
|
+
raise typer.Exit(1) from exc
|
|
316
|
+
|
|
317
|
+
typer.echo(print_json(result))
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Project scaffold and linkage commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from sovaria.models import SovariaError
|
|
10
|
+
from sovaria.project import (
|
|
11
|
+
DEFAULT_CONFIG_FILENAME,
|
|
12
|
+
create_project_from_config_file,
|
|
13
|
+
write_project_config_template,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from apps.cli.src.utils.config import set_scope
|
|
17
|
+
from apps.cli.src.utils.display import print_json, print_success
|
|
18
|
+
from apps.cli.src.utils.project_context import (
|
|
19
|
+
find_project_config,
|
|
20
|
+
link_project_to_profile,
|
|
21
|
+
project_status,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(help="Project scaffold and linkage commands")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("init-config")
|
|
28
|
+
def init_config(
|
|
29
|
+
name: str = typer.Argument("sovaria-app", help="Project name used in the generated config"),
|
|
30
|
+
output: str = typer.Option(
|
|
31
|
+
DEFAULT_CONFIG_FILENAME,
|
|
32
|
+
"--output",
|
|
33
|
+
"-o",
|
|
34
|
+
help="Path to write the template config JSON",
|
|
35
|
+
),
|
|
36
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing config file if present"),
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Create a template sovaria.config.json file."""
|
|
39
|
+
try:
|
|
40
|
+
path = write_project_config_template(output, name=name, overwrite=force)
|
|
41
|
+
except SovariaError as exc:
|
|
42
|
+
typer.echo(str(exc), err=True)
|
|
43
|
+
raise typer.Exit(1) from exc
|
|
44
|
+
typer.echo(f"Config template written: {path}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command("create")
|
|
48
|
+
def create(
|
|
49
|
+
config: str = typer.Option(
|
|
50
|
+
DEFAULT_CONFIG_FILENAME,
|
|
51
|
+
"--config",
|
|
52
|
+
"-c",
|
|
53
|
+
help="Path to sovaria project config JSON",
|
|
54
|
+
),
|
|
55
|
+
directory: str = typer.Option(
|
|
56
|
+
".",
|
|
57
|
+
"--dir",
|
|
58
|
+
help="Parent directory where the project folder will be created",
|
|
59
|
+
),
|
|
60
|
+
force: bool = typer.Option(
|
|
61
|
+
False,
|
|
62
|
+
"--force",
|
|
63
|
+
help="Allow scaffold to overwrite existing files when possible",
|
|
64
|
+
),
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Create a project from sovaria.config.json."""
|
|
67
|
+
try:
|
|
68
|
+
result = create_project_from_config_file(config, target_dir=directory, force=force)
|
|
69
|
+
except SovariaError as exc:
|
|
70
|
+
typer.echo(str(exc), err=True)
|
|
71
|
+
raise typer.Exit(1) from exc
|
|
72
|
+
|
|
73
|
+
typer.echo(f"Project created: {result.project_root}")
|
|
74
|
+
typer.echo(f"Config copied: {result.config_path}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command("status")
|
|
78
|
+
def status() -> None:
|
|
79
|
+
"""Show local sovaria.config.json linkage and resolved scope."""
|
|
80
|
+
print_json(project_status())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.command("link")
|
|
84
|
+
def link(
|
|
85
|
+
org_id: str = typer.Option(..., "--org", help="Organisation node ID"),
|
|
86
|
+
project_id: str = typer.Option(..., "--project", help="Project node ID"),
|
|
87
|
+
env_id: str | None = typer.Option(None, "--env", help="Environment ID"),
|
|
88
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
89
|
+
config: Path | None = typer.Option(
|
|
90
|
+
None,
|
|
91
|
+
"--config",
|
|
92
|
+
"-c",
|
|
93
|
+
help="Path to sovaria.config.json (auto-discovered when omitted)",
|
|
94
|
+
),
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Write org/project scope into sovaria.config.json and the active profile."""
|
|
97
|
+
config_path = config or find_project_config()
|
|
98
|
+
if config_path is None:
|
|
99
|
+
typer.echo(
|
|
100
|
+
f"No {DEFAULT_CONFIG_FILENAME} found. Run `sov init` or `sov project init-config` first.",
|
|
101
|
+
err=True,
|
|
102
|
+
)
|
|
103
|
+
raise typer.Exit(1)
|
|
104
|
+
try:
|
|
105
|
+
path = link_project_to_profile(
|
|
106
|
+
org_id=org_id,
|
|
107
|
+
project_id=project_id,
|
|
108
|
+
env_id=env_id,
|
|
109
|
+
profile=profile,
|
|
110
|
+
config_path=config_path,
|
|
111
|
+
)
|
|
112
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
113
|
+
typer.echo(str(exc), err=True)
|
|
114
|
+
raise typer.Exit(1) from exc
|
|
115
|
+
print_success(f"Linked project scope in {path}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.command("use")
|
|
119
|
+
def use(
|
|
120
|
+
org_id: str = typer.Argument(..., help="Organisation node ID"),
|
|
121
|
+
project_id: str = typer.Argument(..., help="Project node ID"),
|
|
122
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
123
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Set default scope for CLI commands (profile only, no config file write)."""
|
|
126
|
+
set_scope(org_id=org_id, project_id=project_id, env_id=env_id, profile=profile)
|
|
127
|
+
print_success("Active scope updated.")
|
|
128
|
+
typer.echo(f" org_id : {org_id}")
|
|
129
|
+
typer.echo(f" project_id : {project_id}")
|
|
130
|
+
typer.echo(f" env_id : {env_id or '-'}")
|