Spaces:
Running
Running
Apply PR #7 security changes directly to main: app/security/cli.py
Browse files- app/security/cli.py +67 -0
app/security/cli.py
CHANGED
|
@@ -3,12 +3,16 @@ from __future__ import annotations
|
|
| 3 |
import argparse
|
| 4 |
import asyncio
|
| 5 |
import json
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
from app.core.config import get_settings
|
| 8 |
from app.security.database import SecurityDatabase
|
| 9 |
from app.security.schemas import APIKeyCreate, APIKeyView
|
| 10 |
from app.security.service import APIKeyService
|
| 11 |
from app.security.tenancy import TenantService
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
async def _create(arguments: argparse.Namespace) -> None:
|
|
@@ -33,6 +37,58 @@ async def _create(arguments: argparse.Namespace) -> None:
|
|
| 33 |
await database.close()
|
| 34 |
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
def _bootstrap(arguments: argparse.Namespace) -> None:
|
| 37 |
material = APIKeyService.generate_material(arguments.environment)
|
| 38 |
print("Store the API key in your password manager. It will not be shown again.\n")
|
|
@@ -49,6 +105,15 @@ def main() -> None:
|
|
| 49 |
"generate-bootstrap", help="Generate a key and hash-only bootstrap settings"
|
| 50 |
)
|
| 51 |
bootstrap.add_argument("--environment", choices=("live", "test"), default="live")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
create = commands.add_parser("create", help="Create a key directly in the configured database")
|
| 53 |
create.add_argument("--name", required=True)
|
| 54 |
create.add_argument("--environment", choices=("live", "test"), default="live")
|
|
@@ -57,6 +122,8 @@ def main() -> None:
|
|
| 57 |
arguments = parser.parse_args()
|
| 58 |
if arguments.command == "generate-bootstrap":
|
| 59 |
_bootstrap(arguments)
|
|
|
|
|
|
|
| 60 |
else:
|
| 61 |
asyncio.run(_create(arguments))
|
| 62 |
|
|
|
|
| 3 |
import argparse
|
| 4 |
import asyncio
|
| 5 |
import json
|
| 6 |
+
from uuid import uuid4
|
| 7 |
+
|
| 8 |
+
from sqlalchemy import select
|
| 9 |
|
| 10 |
from app.core.config import get_settings
|
| 11 |
from app.security.database import SecurityDatabase
|
| 12 |
from app.security.schemas import APIKeyCreate, APIKeyView
|
| 13 |
from app.security.service import APIKeyService
|
| 14 |
from app.security.tenancy import TenantService
|
| 15 |
+
from app.security.models import User, Workspace, WorkspaceMembership
|
| 16 |
|
| 17 |
|
| 18 |
async def _create(arguments: argparse.Namespace) -> None:
|
|
|
|
| 37 |
await database.close()
|
| 38 |
|
| 39 |
|
| 40 |
+
|
| 41 |
+
async def _provision_studio_user(arguments: argparse.Namespace) -> None:
|
| 42 |
+
"""Explicit operator-driven onboarding; never grant access from a browser request."""
|
| 43 |
+
settings = get_settings()
|
| 44 |
+
database = SecurityDatabase(settings.database_url, auto_migrate=settings.security_auto_migrate)
|
| 45 |
+
await database.initialize()
|
| 46 |
+
try:
|
| 47 |
+
async with database.session() as session:
|
| 48 |
+
existing = await session.scalar(select(User).where(User.subject == arguments.subject))
|
| 49 |
+
if existing is not None:
|
| 50 |
+
raise SystemExit("That identity subject is already provisioned; refusing to reassign it.")
|
| 51 |
+
workspace = await session.scalar(
|
| 52 |
+
select(Workspace).where(Workspace.slug == arguments.workspace_slug)
|
| 53 |
+
)
|
| 54 |
+
if workspace is not None:
|
| 55 |
+
raise SystemExit("Workspace slug already exists; refusing to attach an initial owner implicitly.")
|
| 56 |
+
user_id = str(uuid4())
|
| 57 |
+
workspace_id = str(uuid4())
|
| 58 |
+
user = User(
|
| 59 |
+
id=user_id,
|
| 60 |
+
subject=arguments.subject,
|
| 61 |
+
display_name=arguments.display_name,
|
| 62 |
+
metadata_json={"provisioned_by": "security-cli"},
|
| 63 |
+
)
|
| 64 |
+
workspace = Workspace(
|
| 65 |
+
id=workspace_id,
|
| 66 |
+
slug=arguments.workspace_slug,
|
| 67 |
+
name=arguments.workspace_name,
|
| 68 |
+
metadata_json={"provisioned_by": "security-cli"},
|
| 69 |
+
)
|
| 70 |
+
membership = WorkspaceMembership(
|
| 71 |
+
id=str(uuid4()),
|
| 72 |
+
workspace_id=workspace_id,
|
| 73 |
+
user_id=user_id,
|
| 74 |
+
role=arguments.role,
|
| 75 |
+
status="active",
|
| 76 |
+
)
|
| 77 |
+
session.add_all([user, workspace, membership])
|
| 78 |
+
await session.commit()
|
| 79 |
+
print(json.dumps({
|
| 80 |
+
"user_id": user_id,
|
| 81 |
+
"subject": arguments.subject,
|
| 82 |
+
"workspace_id": workspace_id,
|
| 83 |
+
"workspace_slug": arguments.workspace_slug,
|
| 84 |
+
"membership_id": membership.id,
|
| 85 |
+
"role": arguments.role,
|
| 86 |
+
"status": "active",
|
| 87 |
+
}, indent=2))
|
| 88 |
+
finally:
|
| 89 |
+
await database.close()
|
| 90 |
+
|
| 91 |
+
|
| 92 |
def _bootstrap(arguments: argparse.Namespace) -> None:
|
| 93 |
material = APIKeyService.generate_material(arguments.environment)
|
| 94 |
print("Store the API key in your password manager. It will not be shown again.\n")
|
|
|
|
| 105 |
"generate-bootstrap", help="Generate a key and hash-only bootstrap settings"
|
| 106 |
)
|
| 107 |
bootstrap.add_argument("--environment", choices=("live", "test"), default="live")
|
| 108 |
+
provision = commands.add_parser(
|
| 109 |
+
"provision-studio-user",
|
| 110 |
+
help="Explicitly provision a verified Supabase subject and initial workspace membership",
|
| 111 |
+
)
|
| 112 |
+
provision.add_argument("--subject", required=True, help="Exact Supabase auth user UUID/subject")
|
| 113 |
+
provision.add_argument("--display-name", required=True)
|
| 114 |
+
provision.add_argument("--workspace-slug", required=True)
|
| 115 |
+
provision.add_argument("--workspace-name", required=True)
|
| 116 |
+
provision.add_argument("--role", choices=("owner", "admin", "member"), default="owner")
|
| 117 |
create = commands.add_parser("create", help="Create a key directly in the configured database")
|
| 118 |
create.add_argument("--name", required=True)
|
| 119 |
create.add_argument("--environment", choices=("live", "test"), default="live")
|
|
|
|
| 122 |
arguments = parser.parse_args()
|
| 123 |
if arguments.command == "generate-bootstrap":
|
| 124 |
_bootstrap(arguments)
|
| 125 |
+
elif arguments.command == "provision-studio-user":
|
| 126 |
+
asyncio.run(_provision_studio_user(arguments))
|
| 127 |
else:
|
| 128 |
asyncio.run(_create(arguments))
|
| 129 |
|