diff --git a/.env.example b/.env.example index 817b6c61..66dab6af 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,8 @@ POSTGRES_PORT=54321 BACKEND_PORT=8000 FRONTEND_PORT=5173 +APP_ENV=development +PRODUCTION_CONFIG_CHECKS_ENABLED=true # Production edge port used by compose.prod.yaml (Traefik). TRAEFIK_HTTP_PORT=8080 @@ -31,11 +33,77 @@ SHARE_LINK_RATE_LIMIT_ENABLED=true SHARE_LINK_RATE_LIMIT_REQUESTS=30 SHARE_LINK_RATE_LIMIT_WINDOW_SECONDS=60 SHARE_LINK_RATE_LIMIT_MAX_KEYS=10000 +# Default public share-link lifetime. Set 0 only for an explicit non-expiring exception. +SHARE_LINK_DEFAULT_TTL_HOURS=168 +# Public share links keep local markdown/prompt export enabled, but live LLM +# drafts are disabled by default to avoid unauthenticated provider-cost abuse. +SHARE_LINK_LLM_DRAFT_ENABLED=false # Optional: OIDC (Casdoor) issuer; if set backend verifies JWTs # OIDC_ISSUER=http://localhost:8002 # OIDC_AUDIENCE=erd-local +# Required when APP_ENV=production: exact hostnames/IPs or wildcard domains +# that reverse-engineering jobs may connect to. +# DB_INTROSPECTION_ALLOWED_HOSTS=db.example.com,*.internal.example.com + +# Optional commercial licensing. +# Set LICENSE_MODE=required for paid/on-prem distributions. Either provide a +# legacy shared LICENSE_KEY or an Ed25519 LICENSE_PUBLIC_KEY for signed offline +# tokens. The public-key path supports expiring customer/plan license tokens. +LICENSE_MODE=off +# LICENSE_KEY=change-me-please-strong-key +# LICENSE_PUBLIC_KEY=base64url-ed25519-public-key +# Optional comma-separated revocation lists for signed token jti/sub claims. +# LICENSE_REVOKED_TOKEN_IDS=license-2026-07,license-2026-08 +# LICENSE_REVOKED_SUBJECTS=customer-acme +# Optional paid-plan usage limits. 0 means unlimited. +BILLING_MAX_PROJECTS_PER_USER=0 +BILLING_MAX_CONNECTIONS_PER_PROJECT=0 +BILLING_MAX_SNAPSHOTS_PER_PROJECT=0 +BILLING_MAX_SHARE_LINKS_PER_PROJECT=0 +# Optional billing and account lifecycle links returned by /api/billing/usage +# and account-deactivated/plan-change/checkout responses. In production these +# must be public HTTPS URLs. +# BILLING_CHECKOUT_URL=https://billing.example.com/checkout +# BILLING_PORTAL_URL=https://billing.example.com +# BILLING_SUPPORT_URL=https://support.example.com/billing +# Shared secret required by POST /api/billing/events for provider-neutral +# reconciliation events. In production it must be at least 24 characters. Store +# as a secret, not in source control. +# BILLING_WEBHOOK_SECRET=change-me-provider-webhook-secret +# Optional HMAC-SHA256 raw-body signature secret for provider/gateway webhooks. +# When set, POST /api/billing/events requires X-BILLING-WEBHOOK-SIGNATURE. In +# production it must be at least 32 characters. +# BILLING_WEBHOOK_SIGNATURE_SECRET=change-me-provider-signature-secret +# Optional event-derived contract state. External providers should normalize +# provider-specific events into these event_type values before posting, or set +# BILLING_EVENT_TYPE_ALIASES as source=target entries. Prefix source with +# provider: to avoid cross-provider collisions. +# BILLING_EVENT_TYPE_ALIASES=stripe:customer.subscription.deleted=contract.suspended,customer.subscription.updated=contract.reactivated +# Billing event types that support diagnostics may treat as active entitlement +# evidence for current plan and contracted seat count. +BILLING_ENTITLEMENT_EVENT_TYPES=checkout.completed,invoice.paid,subscription.created,subscription.updated,contract.activated,contract.reactivated +BILLING_CONTRACT_STATE_EVENTS_ENABLED=false +BILLING_CONTRACT_DEACTIVATED_EVENT_TYPES=contract.deactivated,contract.suspended +BILLING_CONTRACT_ACTIVE_EVENT_TYPES=contract.activated,contract.reactivated +# ACCOUNT_REACTIVATION_URL=https://billing.example.com/reactivate +# Optional comma-separated OIDC subjects denied before DB access. +# ACCOUNT_DEACTIVATED_SUBJECTS=customer-owner,contract-suspended-user +# Optional comma-separated OIDC subjects allowed to read support diagnostics. +# SUPPORT_OPERATOR_SUBJECTS=support-operator-1,support-operator-2 + # Optional: allowed JWT signing algorithms (comma-separated). # Default is RS256. # OIDC_ALGORITHMS=RS256 + +# Optional: OpenAI-compatible provider for authenticated live LLM drafts. +# LLM_API_BASE_URL=https://llm.example/v1 +# LLM_API_KEY=change-me +# LLM_MODEL=example-model +LLM_TIMEOUT_SECONDS=30 +LLM_MAX_PROMPT_CHARS=120000 +LLM_MAX_OUTPUT_TOKENS=1200 +LLM_DRAFT_QUOTA_ENABLED=true +LLM_DRAFT_QUOTA_REQUESTS=20 +LLM_DRAFT_QUOTA_WINDOW_SECONDS=3600 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c235f0d..15705878 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,38 @@ jobs: PYTHONPATH: . run: pytest -q + - name: Commercial evidence tests + run: python -m pytest scripts/operations/test_*.py scripts/ci/test_*.py -q + + - name: Commercial release approval manifests + run: python scripts/ci/validate_commercial_release_approval.py + + - name: Commercial readiness audit dry run + run: python scripts/ci/commercial_readiness_audit.py --output /tmp/commercial-readiness-audit.json + + - name: Support bundle evidence manifests + run: python scripts/ci/validate_support_bundle.py + + - name: Billing provider catalog manifest + run: python scripts/ci/validate_billing_provider_catalog.py + + - name: Billing runtime evidence smoke + run: | + python scripts/operations/build_billing_runtime_evidence.py \ + --catalog docs/operations/billing-provider-catalog.example.json \ + --env-file docs/operations/billing-runtime.env.example \ + --output /tmp/billing-runtime-evidence.json + python scripts/ci/validate_billing_runtime_evidence.py /tmp/billing-runtime-evidence.json + + - name: Restore drill evidence manifests + run: python scripts/ci/validate_restore_drill_manifest.py + + - name: Rollback drill evidence manifests + run: python scripts/ci/validate_rollback_drill_manifest.py + + - name: On-premises package smoke + run: python scripts/ci/validate_onprem_package.py + frontend: runs-on: ubuntu-latest steps: @@ -65,6 +97,10 @@ jobs: working-directory: frontend run: npm ci + - name: Install Playwright browser + working-directory: frontend + run: npx playwright install --with-deps chromium + - name: Typecheck working-directory: frontend run: npm run typecheck @@ -73,6 +109,18 @@ jobs: working-directory: frontend run: npm run test + - name: Accessibility smoke + working-directory: frontend + run: npm run test:a11y + + - name: Browser E2E smoke + working-directory: frontend + run: npm run test:e2e + + - name: Visual regression + working-directory: frontend + run: npm run test:visual + - name: Build working-directory: frontend run: npm run build diff --git a/.jules/sentinel.md b/.jules/sentinel.md index c704035b..d010914b 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -57,3 +57,8 @@ **Vulnerability:** Database driver exceptions can echo DSN fragments, query parameters, or assignment-style secrets after connection failures, leaking plaintext passwords through snapshot error messages and queue logs. **Learning:** Redacting only the literal DSN is not enough. Error messages may contain decoded, percent-encoded, query-string, or `password=`/`api_key=` style forms of the same secret. **Prevention:** Sanitize snapshot job errors before persisting or re-raising them, and raise sanitized exceptions with `from None` so Python exception chaining does not reattach the original secret-bearing exception. + +## 2025-02-28 - [Credential Leak via Unhandled DSN URL Schemes] +**Vulnerability:** Python's `urllib.parse.urlsplit` fails to parse URL schemes containing underscores (like `snowflake_invalid://`), causing it to yield an empty scheme and unparsed network location. This resulted in the password candidate extraction logic failing to find and redact secrets in DSN strings with unusual schemes, exposing credentials in error logs and responses when connecting to incorrectly formatted DSNs. +**Learning:** URL parsing libraries may have strict syntax rules for schemes (RFC 3986) that reject common typographical errors (like underscores). When handling security-critical extractions like password redaction, you cannot rely entirely on standard URL parsers without handling malformed inputs. +**Prevention:** Before parsing DSNs specifically for secret redaction or extraction, ensure the scheme is either validated strictly, or apply a fallback/workaround (e.g., replacing the unknown scheme with a standard one temporarily) to ensure the parser correctly separates the user/password portion of the URI. diff --git a/README.md b/README.md index c9840e94..6bdd54bb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,20 @@ -# pg-erd-cloud (MVP skeleton) +# pg-erd-cloud -PostgreSQL 중심 클라우드 ERD(협업/공유) 소프트웨어의 **실행 가능한 MVP 골격**입니다. +PostgreSQL 중심 클라우드 ERD(협업/공유) 소프트웨어입니다. +현재 상용화 준비 단계이며, 판매 가능성 기준은 [상용화 준비 계획](docs/commercial-readiness.md)로 운영합니다. + +## 운영 문서(필수) + +- 공개 공유 보안/운영: [상용 준비 계획](docs/commercial-readiness.md) +- 앱 DB 백업/복구: [backup-restore.md](docs/operations/backup-restore.md) +- 마이그레이션 롤백 정책: [migration-rollback.md](docs/operations/migration-rollback.md) +- 장애 대응: [incident-response.md](docs/operations/incident-response.md) +- 판매 준비 법무 문서: + - [라이선스/결제 가이드](docs/legal/license-billing.md) + - [개인정보 처리방침](docs/legal/privacy-policy.md) + - [이용약관](docs/legal/terms-of-service.md) + - [SLA/지원 기준](docs/legal/sla-support.md) + - [상용 릴리즈 승인 체크리스트](docs/legal/commercial-release-approval.md) ## 제공 기능(현재) @@ -15,7 +29,7 @@ PostgreSQL 중심 클라우드 ERD(협업/공유) 소프트웨어의 **실행 PK/UNIQUE/FK 메타데이터를 수집하고 `source_dialect: "snowflake"` 스냅샷으로 저장합니다. 실행 환경에는 선택 의존성 `snowflake-connector-python`이 필요합니다. - **ERD UI**: React Flow(MIT)로 PK/FK를 그래픽으로 렌더링 -- **Forward engineering(포워드)**: MVP 단계에서는 “스냅샷 기반 DDL(export)” 중심 +- **Forward engineering(포워드)**: 현재 제공 범위는 “스냅샷 기반 DDL(export)” 중심 (diff/변경 SQL 생성은 로드맵) - SQL export: `GET /api/snapshots/{snapshot_uuid}/export.sql` @@ -30,8 +44,11 @@ PostgreSQL 중심 클라우드 ERD(협업/공유) 소프트웨어의 **실행 - Live LLM draft: `GET /api/snapshots/{snapshot_uuid}/reversing-spec.md?mode=llm-draft` - `LLM_API_BASE_URL`, `LLM_API_KEY`, `LLM_MODEL`을 설정한 OpenAI-compatible chat-completions provider를 호출합니다. + - `LLM_MAX_PROMPT_CHARS`와 `LLM_MAX_OUTPUT_TOKENS`로 prompt 크기와 provider + 출력 토큰 상한을 제한합니다. - Share link에서도 동일한 `/api/share/{share_uuid}/snapshots/{snapshot_uuid}/...` - 경로를 사용할 수 있습니다. + 경로를 사용할 수 있습니다. 단, 공개 공유 링크의 `mode=llm-draft`는 + `SHARE_LINK_LLM_DRAFT_ENABLED=true`로 명시적으로 허용하기 전까지 차단됩니다. - **컬럼 예시값 힌트**: 리버스 스냅샷의 각 컬럼에 `example_value`를 추가합니다. 실제 테이블 데이터를 샘플링하지 않고 컬럼명/타입 메타데이터로 만든 합성 예시라서 ERD, PlantUML/SVG export, 명세서, LLM prompt에서 안전하게 참고할 수 있습니다. @@ -45,8 +62,13 @@ curl -X POST "http://localhost:8000/api/projects//share-links" ``` 반환된 `url_path`로 동료가 최신 스냅샷 목록/스냅샷 JSON/DDL export를 조회할 수 있습니다. +공유 링크는 기본적으로 `SHARE_LINK_DEFAULT_TTL_HOURS=168`(7일) 뒤 만료됩니다. +오너는 `GET /api/projects/{project_uuid}/share-links`로 링크를 조회하고, +`DELETE /api/projects/{project_uuid}/share-links/{share_uuid}`로 폐기할 수 있습니다. `/api/share/*` 공개 조회/내보내기 경로는 전역 `/api/*` 제한보다 더 엄격한 별도 IP 기반 rate limit을 적용합니다. +공유 링크의 `markdown`/`llm-prompt` export는 로컬 생성만 수행하지만, `llm-draft` +export는 외부 LLM provider 비용을 만들 수 있으므로 기본값에서 비활성화되어 있습니다. ## 인덱스 타입 지원 원칙 @@ -104,6 +126,12 @@ PY # Restrict secret file permissions (owner read/write only) chmod 600 secrets/app_secret +# compose.prod.yaml은 APP_ENV=production으로 실행됩니다. 아래 값을 localhost가 아닌 +# 운영 값으로 바꾸세요. 누락되면 백엔드는 시작 단계에서 실패합니다. +# - OIDC_ISSUER / OIDC_AUDIENCE +# - CORS_ORIGINS=https://erd.example.com +# - DB_INTROSPECTION_ALLOWED_HOSTS=db.example.com,*.internal.example.com + docker compose -f compose.prod.yaml up -d --build ``` @@ -146,6 +174,10 @@ pip install -e ".[snowflake]" - `APP_SECRET`은 앱 DB에 저장되는 DSN 암호화 키로 사용되므로, 변경 시 기존 데이터 복호화에 영향을 줄 수 있습니다. 가능하면 `APP_SECRET_FILE`(예: `/run/secrets/app_secret`) 방식으로 안전하게 주입하세요. +- `APP_ENV=production`에서는 OIDC, 공개 HTTPS CORS origin, 대상 DB allowlist, + 32자 이상의 secret, 공유 링크 기본 만료가 startup guard로 강제됩니다. +- 백업/복구 절차는 [docs/operations/backup-restore.md](docs/operations/backup-restore.md)를 + 기준으로 운영합니다. ### Frontend diff --git a/SECURITY.md b/SECURITY.md index a3ef6dc0..f057c220 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,3 +23,10 @@ We aim to: - Fix the issue and coordinate disclosure within **90 days**, when feasible Timelines may vary depending on severity, complexity, and downstream impact. + +## Commercial Release Gate + +Paid SaaS or on-prem releases must identify the person or team responsible for +security triage in `docs/legal/commercial-release-approval.md` before customer +delivery. A release without a private advisory path, triage owner, and customer +notification owner is not considered sales-ready. diff --git a/backend/alembic/versions/0003_revoked_token.py b/backend/alembic/versions/0003_revoked_token.py index b7bcce29..88296542 100644 --- a/backend/alembic/versions/0003_revoked_token.py +++ b/backend/alembic/versions/0003_revoked_token.py @@ -1,7 +1,7 @@ """revoked_token Revision ID: 0003 -Revises: 0002 +Revises: 0002_auth_share Create Date: 2026-06-22 10:00:00.000000 """ @@ -12,7 +12,7 @@ # revision identifiers, used by Alembic. revision = "0003" -down_revision = "0002" +down_revision = "0002_auth_share" branch_labels = None depends_on = None diff --git a/backend/alembic/versions/0004_billing_event.py b/backend/alembic/versions/0004_billing_event.py new file mode 100644 index 00000000..57356f0f --- /dev/null +++ b/backend/alembic/versions/0004_billing_event.py @@ -0,0 +1,51 @@ +"""billing event reconciliation + +Revision ID: 0004_billing_event +Revises: 0003, 0003_validate_project_space_fk +Create Date: 2026-07-02 + +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0004_billing_event" +down_revision = ("0003", "0003_validate_project_space_fk") +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "billing_event", + sa.Column("billing_event_uuid", sa.Uuid(), primary_key=True, nullable=False), + sa.Column("provider", sa.Text(), nullable=False), + sa.Column("provider_event_id", sa.Text(), nullable=False), + sa.Column("event_type", sa.Text(), nullable=False), + sa.Column("subject", sa.Text(), nullable=False), + sa.Column("target_plan", sa.Text(), nullable=True), + sa.Column("event_status", sa.Text(), nullable=False), + sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("received_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("metadata_json", sa.JSON(), nullable=False), + sa.UniqueConstraint( + "provider", + "provider_event_id", + name="uq_billing_event__provider_event_id", + ), + ) + op.create_index( + "ix_billing_event__subject_received_at", + "billing_event", + ["subject", "received_at"], + ) + + +def downgrade() -> None: + op.drop_index( + "ix_billing_event__subject_received_at", + table_name="billing_event", + ) + op.drop_table("billing_event") diff --git a/backend/alembic/versions/0005_llm_draft_usage_event.py b/backend/alembic/versions/0005_llm_draft_usage_event.py new file mode 100644 index 00000000..e44d4703 --- /dev/null +++ b/backend/alembic/versions/0005_llm_draft_usage_event.py @@ -0,0 +1,83 @@ +"""llm draft usage billing attribution + +Revision ID: 0005_llm_draft_usage_event +Revises: 0004_billing_event +Create Date: 2026-07-03 + +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0005_llm_draft_usage_event" +down_revision = "0004_billing_event" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "llm_draft_usage_event", + sa.Column( + "llm_draft_usage_event_uuid", + sa.Uuid(), + primary_key=True, + nullable=False, + ), + sa.Column("surface", sa.Text(), nullable=False), + sa.Column("artifact", sa.Text(), nullable=False), + sa.Column("outcome", sa.Text(), nullable=False), + sa.Column("subject", sa.Text(), nullable=True), + sa.Column("user_account_uuid", sa.Uuid(), nullable=True), + sa.Column("project_space_uuid", sa.Uuid(), nullable=True), + sa.Column("schema_snapshot_uuid", sa.Uuid(), nullable=True), + sa.Column("share_link_uuid", sa.Uuid(), nullable=True), + sa.Column("input_chars", sa.Integer(), nullable=False), + sa.Column("output_chars", sa.Integer(), nullable=True), + sa.Column("error_code", sa.Text(), nullable=True), + sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["user_account_uuid"], + ["user_account.user_account_uuid"], + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["project_space_uuid"], + ["project_space.project_space_uuid"], + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["schema_snapshot_uuid"], + ["schema_snapshot.schema_snapshot_uuid"], + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["share_link_uuid"], + ["share_link.share_link_uuid"], + ondelete="SET NULL", + ), + ) + op.create_index( + "ix_llm_draft_usage_event__account_month", + "llm_draft_usage_event", + ["user_account_uuid", "occurred_at"], + ) + op.create_index( + "ix_llm_draft_usage_event__project_month", + "llm_draft_usage_event", + ["project_space_uuid", "occurred_at"], + ) + + +def downgrade() -> None: + op.drop_index( + "ix_llm_draft_usage_event__project_month", + table_name="llm_draft_usage_event", + ) + op.drop_index( + "ix_llm_draft_usage_event__account_month", + table_name="llm_draft_usage_event", + ) + op.drop_table("llm_draft_usage_event") diff --git a/backend/app/api/billing.py b/backend/app/api/billing.py new file mode 100644 index 00000000..8ee731e8 --- /dev/null +++ b/backend/app/api/billing.py @@ -0,0 +1,968 @@ +from __future__ import annotations + +import datetime as dt +import hashlib +import hmac +import uuid +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Literal +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request +from sqlalchemy import case, desc, distinct, func, or_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql import Executable + +from app.auth import CurrentUser, get_current_user +from app.billing_entitlements import billing_entitlement_from_events +from app.contract_state import ( + latest_contract_state_for_subject, + normalize_billing_event_type, +) +from app.db import get_read_session, get_session +from app.models import ( + BillingEvent, + DbConnection, + LlmDraftUsageEvent, + ProjectMember, + ProjectSpace, + SchemaSnapshot, + ShareLink, + UserAccount, + utcnow, +) +from app.metrics import BILLING_EVENTS_TOTAL +from app.sanitize import sanitize_for_storage +from app.schemas import ( + BillingCheckoutOut, + BillingEntitlementOut, + BillingEventMetadataSummaryOut, + BillingEventIn, + BillingEventOut, + BillingEventSummaryOut, + BillingLlmUsageOut, + BillingPlanChangeIn, + BillingPlanChangeOut, + BillingSeatReconciliationCandidateOut, + BillingSeatReconciliationOut, + BillingSupportAccountOut, + BillingSupportShareLinkSummaryOut, + BillingUsageOut, +) +from app.settings import settings + +router = APIRouter(prefix="/api/billing", tags=["billing"]) +_BILLING_WEBHOOK_SECRET_HEADER = "X-BILLING-WEBHOOK-SECRET" +_BILLING_WEBHOOK_SIGNATURE_HEADER = "X-BILLING-WEBHOOK-SIGNATURE" +_SENSITIVE_METADATA_KEY_PARTS = ( + "api_key", + "authorization", + "card", + "client_secret", + "dsn", + "password", + "secret", + "token", +) +_METADATA_SUMMARY_ITEM_LIMIT = 8 +_METADATA_SUMMARY_KEY_LIMIT = 96 +_METADATA_SUMMARY_VALUE_LIMIT = 240 + +LicenseVerifierKind = Literal[ + "none", "static_key", "signed_token", "static_key_and_signed_token" +] + + +@dataclass(frozen=True) +class BillingUsageCounts: + project_count: int + seat_count: int + connection_count: int + snapshot_count: int + share_link_count: int + active_share_link_count: int + + +@dataclass(frozen=True) +class BillingLlmUsageCounts: + request_count: int + success_count: int + failure_count: int + quota_exceeded_count: int + input_chars: int + output_chars: int + + +@dataclass(frozen=True) +class BillingSeatReconciliationCandidate: + member_subject: str + project_count: int + + +def _license_verifier_kind() -> LicenseVerifierKind: + has_static_key = bool(settings.license_key) + has_signed_token_verifier = bool(settings.license_public_key) + if has_static_key and has_signed_token_verifier: + return "static_key_and_signed_token" + if has_signed_token_verifier: + return "signed_token" + if has_static_key: + return "static_key" + return "none" + + +async def _scalar_count(session: AsyncSession, stmt: Executable) -> int: + result = await session.execute(stmt) + value = result.scalar_one() + return int(value or 0) + + +def _split_csv(value: str) -> set[str]: + return {item.strip() for item in value.split(",") if item.strip()} + + +def _require_allowed_target_plan(target_plan: str | None) -> None: + if target_plan is None: + return + allowed_plans = _split_csv(settings.billing_allowed_plans) + if allowed_plans and target_plan not in allowed_plans: + raise HTTPException( + status_code=422, + detail="target plan is not in configured billing catalog", + ) + + +async def _account_status_for_subject( + session: AsyncSession, + subject: str, + user_account_uuid: uuid.UUID | None, +) -> Literal["active", "deactivated", "unknown"]: + if subject in _split_csv(settings.account_deactivated_subjects): + return "deactivated" + if await latest_contract_state_for_subject(session, subject) == "deactivated": + return "deactivated" + if user_account_uuid is None: + return "unknown" + return "active" + + +def _require_support_operator(user: CurrentUser) -> None: + if user.subject not in _split_csv(settings.support_operator_subjects): + raise HTTPException(status_code=403, detail="support operator role required") + + +def _portal_url_with_target_plan(base_url: str, target_plan: str) -> str: + parts = urlsplit(base_url) + query_items = [ + (key, value) + for key, value in parse_qsl(parts.query, keep_blank_values=True) + if key != "target_plan" + ] + query_items.append(("target_plan", target_plan)) + return urlunsplit( + ( + parts.scheme, + parts.netloc, + parts.path, + urlencode(query_items), + parts.fragment, + ) + ) + + +async def _usage_counts_for_owner( + session: AsyncSession, + user_account_uuid: uuid.UUID, +) -> BillingUsageCounts: + owned_project_ids = select(ProjectSpace.project_space_uuid).where( + ProjectSpace.created_by_user_uuid == user_account_uuid + ) + now = dt.datetime.now(dt.timezone.utc) + + return BillingUsageCounts( + project_count=await _scalar_count( + session, + select(func.count()) + .select_from(ProjectSpace) + .where(ProjectSpace.created_by_user_uuid == user_account_uuid), + ), + seat_count=await _scalar_count( + session, + select(func.count(distinct(ProjectMember.user_account_uuid))).where( + ProjectMember.project_space_uuid.in_(owned_project_ids) + ), + ), + connection_count=await _scalar_count( + session, + select(func.count()).select_from(DbConnection).where( + DbConnection.project_space_uuid.in_(owned_project_ids) + ), + ), + snapshot_count=await _scalar_count( + session, + select(func.count()).select_from(SchemaSnapshot).where( + SchemaSnapshot.project_space_uuid.in_(owned_project_ids) + ), + ), + share_link_count=await _scalar_count( + session, + select(func.count()).select_from(ShareLink).where( + ShareLink.project_space_uuid.in_(owned_project_ids) + ), + ), + active_share_link_count=await _scalar_count( + session, + select(func.count()).select_from(ShareLink).where( + ShareLink.project_space_uuid.in_(owned_project_ids), + or_(ShareLink.expires_at.is_(None), ShareLink.expires_at > now), + ), + ), + ) + + +async def _seat_deprovisioning_candidates_for_owner( + session: AsyncSession, + user_account_uuid: uuid.UUID, + *, + limit: int, +) -> list[BillingSeatReconciliationCandidate]: + if limit <= 0: + return [] + + owned_project_ids = select(ProjectSpace.project_space_uuid).where( + ProjectSpace.created_by_user_uuid == user_account_uuid + ) + project_count = func.count(distinct(ProjectMember.project_space_uuid)) + result = await session.execute( + select(UserAccount.oidc_subject, project_count.label("project_count")) + .select_from(ProjectMember) + .join( + UserAccount, + UserAccount.user_account_uuid == ProjectMember.user_account_uuid, + ) + .where(ProjectMember.project_space_uuid.in_(owned_project_ids)) + .group_by(UserAccount.oidc_subject) + .order_by(project_count.asc(), UserAccount.oidc_subject.asc()) + .limit(limit) + ) + return [ + BillingSeatReconciliationCandidate( + member_subject=str(row[0]), + project_count=int(row[1] or 0), + ) + for row in result.all() + ] + + +def _billing_seat_reconciliation_response( + *, + user_account_uuid: uuid.UUID | None, + usage_counts: BillingUsageCounts, + entitlement: BillingEntitlementOut, + candidates: list[BillingSeatReconciliationCandidate], +) -> BillingSeatReconciliationOut: + if user_account_uuid is None: + return BillingSeatReconciliationOut( + status="unknown_account", + contracted_seat_count=None, + active_seat_count=0, + seats_over_limit=0, + deprovisioning_required=False, + deprovisioning_candidates=[], + ) + + if entitlement.seat_count is None: + return BillingSeatReconciliationOut( + status="not_configured", + contracted_seat_count=None, + active_seat_count=usage_counts.seat_count, + seats_over_limit=0, + deprovisioning_required=False, + deprovisioning_candidates=[], + ) + + seats_over_limit = max(usage_counts.seat_count - entitlement.seat_count, 0) + return BillingSeatReconciliationOut( + status="over_limit" if seats_over_limit > 0 else "within_limit", + contracted_seat_count=entitlement.seat_count, + active_seat_count=usage_counts.seat_count, + seats_over_limit=seats_over_limit, + deprovisioning_required=seats_over_limit > 0, + deprovisioning_candidates=[ + BillingSeatReconciliationCandidateOut( + member_subject=candidate.member_subject, + project_count=candidate.project_count, + ) + for candidate in candidates + ], + ) + + +def _billing_month_window(month: str | None) -> tuple[str, dt.datetime, dt.datetime]: + if month is None: + now = utcnow() + month = f"{now.year:04d}-{now.month:02d}" + try: + start = dt.datetime.strptime(month, "%Y-%m").replace(tzinfo=dt.timezone.utc) + except ValueError as exc: + raise HTTPException(status_code=422, detail="month must use YYYY-MM") from exc + if start.month == 12: + end = start.replace(year=start.year + 1, month=1) + else: + end = start.replace(month=start.month + 1) + return month, start, end + + +async def _llm_usage_counts_for_account( + session: AsyncSession, + user_account_uuid: uuid.UUID, + *, + start: dt.datetime, + end: dt.datetime, +) -> BillingLlmUsageCounts: + result = await session.execute( + select( + func.count().label("request_count"), + func.coalesce( + func.sum( + case((LlmDraftUsageEvent.outcome == "success", 1), else_=0) + ), + 0, + ).label("success_count"), + func.coalesce( + func.sum( + case((LlmDraftUsageEvent.outcome != "success", 1), else_=0) + ), + 0, + ).label("failure_count"), + func.coalesce( + func.sum( + case( + (LlmDraftUsageEvent.outcome == "quota_exceeded", 1), + else_=0, + ) + ), + 0, + ).label("quota_exceeded_count"), + func.coalesce(func.sum(LlmDraftUsageEvent.input_chars), 0).label( + "input_chars" + ), + func.coalesce(func.sum(LlmDraftUsageEvent.output_chars), 0).label( + "output_chars" + ), + ).where( + LlmDraftUsageEvent.user_account_uuid == user_account_uuid, + LlmDraftUsageEvent.occurred_at >= start, + LlmDraftUsageEvent.occurred_at < end, + ) + ) + row = result.one()._mapping + return BillingLlmUsageCounts( + request_count=int(row["request_count"] or 0), + success_count=int(row["success_count"] or 0), + failure_count=int(row["failure_count"] or 0), + quota_exceeded_count=int(row["quota_exceeded_count"] or 0), + input_chars=int(row["input_chars"] or 0), + output_chars=int(row["output_chars"] or 0), + ) + + +def _empty_llm_usage_counts() -> BillingLlmUsageCounts: + return BillingLlmUsageCounts( + request_count=0, + success_count=0, + failure_count=0, + quota_exceeded_count=0, + input_chars=0, + output_chars=0, + ) + + +def _llm_usage_response( + *, + month: str, + counts: BillingLlmUsageCounts, +) -> BillingLlmUsageOut: + return BillingLlmUsageOut( + scope="account", + month=month, + request_count=counts.request_count, + success_count=counts.success_count, + failure_count=counts.failure_count, + quota_exceeded_count=counts.quota_exceeded_count, + input_chars=counts.input_chars, + output_chars=counts.output_chars, + ) + + +def _redact_billing_metadata(value: object) -> object: + if isinstance(value, Mapping): + redacted: dict[str, object] = {} + for raw_key, raw_value in value.items(): + key = str(raw_key) + key_lower = key.lower() + if any(part in key_lower for part in _SENSITIVE_METADATA_KEY_PARTS): + redacted[key] = "[redacted]" + else: + redacted[key] = _redact_billing_metadata(raw_value) + return sanitize_for_storage(redacted) + if isinstance(value, list): + return [_redact_billing_metadata(item) for item in value] + return sanitize_for_storage(value) + + +def _redacted_billing_metadata(metadata: Mapping[str, object]) -> dict: + redacted = _redact_billing_metadata(metadata) + if isinstance(redacted, dict): + return redacted + return {} + + +def _billing_metadata_for_storage( + payload: BillingEventIn, + normalized_event_type: str, +) -> dict: + metadata = _redacted_billing_metadata(payload.metadata) + if normalized_event_type != payload.event_type: + metadata["raw_event_type"] = payload.event_type + return metadata + + +def _expected_billing_signature(raw_body: bytes) -> str: + secret = settings.billing_webhook_signature_secret + if secret is None: + raise RuntimeError("billing webhook signature secret is not configured") + return hmac.new( + secret.encode("utf-8"), + raw_body, + hashlib.sha256, + ).hexdigest() + + +def _signature_value(header_value: str) -> str: + signature = header_value.strip() + if signature.startswith("sha256="): + return signature.removeprefix("sha256=") + return signature + + +async def _require_valid_billing_webhook_auth( + *, + request: Request, + billing_webhook_secret: str | None, + billing_webhook_signature: str | None, +) -> None: + if not (settings.billing_webhook_secret or settings.billing_webhook_signature_secret): + raise HTTPException( + status_code=503, + detail="billing webhook secret is not configured", + ) + + if settings.billing_webhook_secret and ( + not billing_webhook_secret + or not hmac.compare_digest( + billing_webhook_secret, + settings.billing_webhook_secret, + ) + ): + raise HTTPException(status_code=401, detail="invalid billing webhook secret") + + if settings.billing_webhook_signature_secret: + if not billing_webhook_signature: + raise HTTPException( + status_code=401, + detail="invalid billing webhook signature", + ) + expected = _expected_billing_signature(await request.body()) + provided = _signature_value(billing_webhook_signature) + if not hmac.compare_digest(provided, expected): + raise HTTPException( + status_code=401, + detail="invalid billing webhook signature", + ) + + +async def _find_billing_event( + session: AsyncSession, + provider: str, + provider_event_id: str, +) -> BillingEvent | None: + result = await session.execute( + select(BillingEvent).where( + BillingEvent.provider == provider, + BillingEvent.provider_event_id == provider_event_id, + ) + ) + return result.scalar_one_or_none() + + +def _billing_event_response( + *, + event: BillingEvent, + action: Literal["recorded", "duplicate"], +) -> BillingEventOut: + message = ( + "Billing event recorded for reconciliation." + if action == "recorded" + else "Billing event was already recorded; duplicate ignored." + ) + return BillingEventOut( + action=action, + billing_event_uuid=event.billing_event_uuid, + provider=event.provider, + provider_event_id=event.provider_event_id, + event_type=event.event_type, + subject=event.subject, + target_plan=event.target_plan, + status="recorded", + occurred_at=event.occurred_at, + received_at=event.received_at, + message=message, + ) + + +def _record_billing_event_metric( + payload: BillingEventIn, + outcome: Literal[ + "recorded", + "duplicate", + "rejected_auth", + "rejected_config", + "rejected_catalog", + ], +) -> None: + BILLING_EVENTS_TOTAL.labels( + provider=payload.provider, + event_type=payload.event_type, + outcome=outcome, + ).inc() + + +def _billing_event_summary(event: BillingEvent) -> BillingEventSummaryOut: + return BillingEventSummaryOut( + billing_event_uuid=event.billing_event_uuid, + provider=event.provider, + provider_event_id=event.provider_event_id, + event_type=event.event_type, + target_plan=event.target_plan, + status="recorded", + occurred_at=event.occurred_at, + received_at=event.received_at, + metadata_summary=_billing_metadata_summary(event.metadata_json), + ) + + +def _truncate_metadata_text(value: str, *, limit: int) -> str: + if len(value) <= limit: + return value + return f"{value[: limit - 3]}..." + + +def _metadata_value_text(value: object) -> str: + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int | float | str): + return str(value) + if isinstance(value, list): + return f"{len(value)} items" + return type(value).__name__ + + +def _billing_metadata_summary(metadata: object) -> list[BillingEventMetadataSummaryOut]: + items: list[BillingEventMetadataSummaryOut] = [] + + def add_item(path: str, value: object) -> None: + if len(items) >= _METADATA_SUMMARY_ITEM_LIMIT: + return + key = _truncate_metadata_text(path, limit=_METADATA_SUMMARY_KEY_LIMIT) + text = _truncate_metadata_text( + _metadata_value_text(value), + limit=_METADATA_SUMMARY_VALUE_LIMIT, + ) + if key and text: + items.append(BillingEventMetadataSummaryOut(key=key, value=text)) + + def visit(prefix: str, value: object) -> None: + if len(items) >= _METADATA_SUMMARY_ITEM_LIMIT: + return + if isinstance(value, Mapping): + for raw_key, raw_value in value.items(): + key = str(raw_key).strip() + if not key: + continue + path = f"{prefix}.{key}" if prefix else key + visit(path, raw_value) + if len(items) >= _METADATA_SUMMARY_ITEM_LIMIT: + return + return + add_item(prefix or "value", value) + + visit("", metadata) + return items + + +def _share_link_status( + link: ShareLink, + now: dt.datetime, +) -> Literal["active", "expired"]: + if link.expires_at is None or link.expires_at > now: + return "active" + return "expired" + + +def _share_link_summary( + link: ShareLink, + now: dt.datetime, +) -> BillingSupportShareLinkSummaryOut: + return BillingSupportShareLinkSummaryOut( + share_link_uuid=link.share_link_uuid, + project_space_uuid=link.project_space_uuid, + permission_kind=link.permission_kind, + status=_share_link_status(link, now), + expires_at=link.expires_at, + created_at=link.created_at, + ) + + +async def _recent_billing_event_models( + session: AsyncSession, + subject: str, +) -> list[BillingEvent]: + result = await session.execute( + select(BillingEvent) + .where(BillingEvent.subject == subject) + .order_by(desc(BillingEvent.received_at)) + .limit(10) + ) + return list(result.scalars().all()) + + +async def _recent_share_links_for_owner( + session: AsyncSession, + user_account_uuid: uuid.UUID, +) -> list[BillingSupportShareLinkSummaryOut]: + owned_project_ids = select(ProjectSpace.project_space_uuid).where( + ProjectSpace.created_by_user_uuid == user_account_uuid + ) + result = await session.execute( + select(ShareLink) + .where(ShareLink.project_space_uuid.in_(owned_project_ids)) + .order_by(desc(ShareLink.created_at)) + .limit(10) + ) + now = dt.datetime.now(dt.timezone.utc) + return [_share_link_summary(link, now) for link in result.scalars().all()] + + +@router.get("/usage", response_model=BillingUsageOut) +async def get_billing_usage( + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_read_session), +) -> BillingUsageOut: + """Return current account usage counters for billing/license operations.""" + usage_counts = await _usage_counts_for_owner(session, user.user_account_uuid) + + return BillingUsageOut( + scope="owned_projects", + account_status="active", + license_mode=settings.license_mode, + license_verifier=_license_verifier_kind(), + billing_portal_url=settings.billing_portal_url, + billing_support_url=settings.billing_support_url, + account_reactivation_url=settings.account_reactivation_url, + project_count=usage_counts.project_count, + seat_count=usage_counts.seat_count, + connection_count=usage_counts.connection_count, + snapshot_count=usage_counts.snapshot_count, + share_link_count=usage_counts.share_link_count, + active_share_link_count=usage_counts.active_share_link_count, + project_limit=settings.billing_max_projects_per_user, + connection_limit=settings.billing_max_connections_per_project, + snapshot_limit=settings.billing_max_snapshots_per_project, + share_link_limit=settings.billing_max_share_links_per_project, + ) + + +@router.get("/llm-usage", response_model=BillingLlmUsageOut) +async def get_billing_llm_usage( + month: str | None = Query(default=None, pattern=r"^\d{4}-\d{2}$"), + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_read_session), +) -> BillingLlmUsageOut: + """Return monthly LLM draft usage for account-level billing attribution.""" + normalized_month, start, end = _billing_month_window(month) + counts = await _llm_usage_counts_for_account( + session, + user.user_account_uuid, + start=start, + end=end, + ) + return _llm_usage_response(month=normalized_month, counts=counts) + + +@router.get("/support/account", response_model=BillingSupportAccountOut) +async def get_support_account_billing( + subject: str = Query( + min_length=1, + max_length=128, + pattern=r"^[^\x00-\x1F\x7F]+$", + ), + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_read_session), +) -> BillingSupportAccountOut: + """Return read-only billing/account diagnostics for support operators.""" + _require_support_operator(user) + + account_result = await session.execute( + select(UserAccount).where(UserAccount.oidc_subject == subject) + ) + account = account_result.scalar_one_or_none() + user_account_uuid = account.user_account_uuid if account is not None else None + usage_counts = ( + await _usage_counts_for_owner(session, user_account_uuid) + if user_account_uuid is not None + else BillingUsageCounts( + project_count=0, + seat_count=0, + connection_count=0, + snapshot_count=0, + share_link_count=0, + active_share_link_count=0, + ) + ) + + account_status = await _account_status_for_subject( + session, + subject, + user_account_uuid, + ) + recent_share_links = ( + await _recent_share_links_for_owner(session, user_account_uuid) + if user_account_uuid is not None + else [] + ) + recent_billing_event_models = await _recent_billing_event_models( + session, + subject, + ) + billing_entitlement = billing_entitlement_from_events( + recent_billing_event_models, + ) + seats_over_limit = ( + max(usage_counts.seat_count - billing_entitlement.seat_count, 0) + if billing_entitlement.seat_count is not None + else 0 + ) + seat_deprovisioning_candidates = ( + await _seat_deprovisioning_candidates_for_owner( + session, + user_account_uuid, + limit=seats_over_limit, + ) + if user_account_uuid is not None and seats_over_limit > 0 + else [] + ) + llm_month, llm_start, llm_end = _billing_month_window(None) + llm_usage_counts = ( + await _llm_usage_counts_for_account( + session, + user_account_uuid, + start=llm_start, + end=llm_end, + ) + if user_account_uuid is not None + else _empty_llm_usage_counts() + ) + + return BillingSupportAccountOut( + subject=subject, + user_account_uuid=user_account_uuid, + account_status=account_status, + license_mode=settings.license_mode, + license_verifier=_license_verifier_kind(), + billing_portal_url=settings.billing_portal_url, + billing_support_url=settings.billing_support_url, + account_reactivation_url=settings.account_reactivation_url, + project_count=usage_counts.project_count, + seat_count=usage_counts.seat_count, + connection_count=usage_counts.connection_count, + snapshot_count=usage_counts.snapshot_count, + share_link_count=usage_counts.share_link_count, + active_share_link_count=usage_counts.active_share_link_count, + billing_entitlement=billing_entitlement, + billing_seat_reconciliation=_billing_seat_reconciliation_response( + user_account_uuid=user_account_uuid, + usage_counts=usage_counts, + entitlement=billing_entitlement, + candidates=seat_deprovisioning_candidates, + ), + llm_usage_current_month=_llm_usage_response( + month=llm_month, + counts=llm_usage_counts, + ), + recent_share_links=recent_share_links, + recent_billing_events=[ + _billing_event_summary(event) for event in recent_billing_event_models + ], + ) + + +@router.post("/plan-change", response_model=BillingPlanChangeOut) +async def request_billing_plan_change( + payload: BillingPlanChangeIn, + user: CurrentUser = Depends(get_current_user), +) -> BillingPlanChangeOut: + """Return the configured billing action for a requested plan change.""" + del user + _require_allowed_target_plan(payload.target_plan) + + if settings.billing_portal_url: + return BillingPlanChangeOut( + action="portal_redirect", + target_plan=payload.target_plan, + billing_portal_url=_portal_url_with_target_plan( + settings.billing_portal_url, + payload.target_plan, + ), + billing_support_url=settings.billing_support_url, + message="Open the billing portal to request or complete this plan change.", + ) + + if settings.billing_support_url: + return BillingPlanChangeOut( + action="contact_support", + target_plan=payload.target_plan, + billing_portal_url=None, + billing_support_url=settings.billing_support_url, + message="Contact billing support to request or complete this plan change.", + ) + + raise HTTPException( + status_code=503, + detail="billing plan change path is not configured", + ) + + +@router.post("/checkout", response_model=BillingCheckoutOut) +async def request_billing_checkout( + payload: BillingPlanChangeIn, + user: CurrentUser = Depends(get_current_user), +) -> BillingCheckoutOut: + """Return the configured billing checkout action for a requested plan.""" + del user + _require_allowed_target_plan(payload.target_plan) + + if settings.billing_checkout_url: + return BillingCheckoutOut( + action="checkout_redirect", + target_plan=payload.target_plan, + billing_checkout_url=_portal_url_with_target_plan( + settings.billing_checkout_url, + payload.target_plan, + ), + billing_support_url=settings.billing_support_url, + message="Open checkout to start this plan.", + ) + + if settings.billing_support_url: + return BillingCheckoutOut( + action="contact_support", + target_plan=payload.target_plan, + billing_checkout_url=None, + billing_support_url=settings.billing_support_url, + message="Contact billing support to start this plan.", + ) + + raise HTTPException( + status_code=503, + detail="billing checkout path is not configured", + ) + + +@router.post("/events", response_model=BillingEventOut) +async def ingest_billing_event( + request: Request, + payload: BillingEventIn, + billing_webhook_secret: str | None = Header( + default=None, + alias=_BILLING_WEBHOOK_SECRET_HEADER, + ), + billing_webhook_signature: str | None = Header( + default=None, + alias=_BILLING_WEBHOOK_SIGNATURE_HEADER, + ), + session: AsyncSession = Depends(get_session), +) -> BillingEventOut: + """Record a provider-neutral billing event for support reconciliation.""" + normalized_event_type = normalize_billing_event_type( + payload.event_type, + provider=payload.provider, + ) + normalized_payload = payload.model_copy( + update={"event_type": normalized_event_type}, + ) + try: + await _require_valid_billing_webhook_auth( + request=request, + billing_webhook_secret=billing_webhook_secret, + billing_webhook_signature=billing_webhook_signature, + ) + except HTTPException as exc: + _record_billing_event_metric( + normalized_payload, + "rejected_config" if exc.status_code == 503 else "rejected_auth", + ) + raise + + existing = await _find_billing_event( + session, + provider=normalized_payload.provider, + provider_event_id=normalized_payload.provider_event_id, + ) + if existing is not None: + _record_billing_event_metric(normalized_payload, "duplicate") + return _billing_event_response(event=existing, action="duplicate") + try: + _require_allowed_target_plan(normalized_payload.target_plan) + except HTTPException: + _record_billing_event_metric(normalized_payload, "rejected_catalog") + raise + + now = utcnow() + event = BillingEvent( + billing_event_uuid=uuid.uuid4(), + provider=normalized_payload.provider, + provider_event_id=normalized_payload.provider_event_id, + event_type=normalized_payload.event_type, + subject=normalized_payload.subject, + target_plan=normalized_payload.target_plan, + event_status="recorded", + occurred_at=normalized_payload.occurred_at or now, + received_at=now, + metadata_json=_billing_metadata_for_storage( + payload, + normalized_payload.event_type, + ), + ) + session.add(event) + + try: + await session.commit() + except IntegrityError: + await session.rollback() + duplicate = await _find_billing_event( + session, + provider=normalized_payload.provider, + provider_event_id=normalized_payload.provider_event_id, + ) + if duplicate is None: + raise + _record_billing_event_metric(normalized_payload, "duplicate") + return _billing_event_response(event=duplicate, action="duplicate") + + _record_billing_event_metric(normalized_payload, "recorded") + return _billing_event_response(event=event, action="recorded") diff --git a/backend/app/api/connections.py b/backend/app/api/connections.py index 5689ce62..f3d78a52 100644 --- a/backend/app/api/connections.py +++ b/backend/app/api/connections.py @@ -3,17 +3,19 @@ import datetime as dt import uuid -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth import CurrentUser, get_current_user from app.db import get_read_session, get_session +from app.metrics import record_product_event from app.models import DbConnection from app.permissions import require_project_member from app.schemas import ConnectionCreateIn, ConnectionOut from app.security import encrypt_text from app.sanitize import sanitize_for_storage +from app.usage_quotas import enforce_connection_quota router = APIRouter(prefix="/api/connections", tags=["connections"]) @@ -46,9 +48,15 @@ async def create_connection( session: AsyncSession = Depends(get_session), ) -> ConnectionOut: """Create a DB connection for a project (encrypt DSN at rest).""" - await require_project_member( - session, project_space_uuid, user.user_account_uuid, minimum_role="editor" - ) + try: + await require_project_member( + session, project_space_uuid, user.user_account_uuid, minimum_role="editor" + ) + await enforce_connection_quota(session, project_space_uuid) + except HTTPException: + record_product_event("connection", "create", "denied") + raise + encrypted = encrypt_text(str(sanitize_for_storage(body.dsn))) c = DbConnection( db_connection_uuid=uuid.uuid4(), @@ -61,4 +69,5 @@ async def create_connection( ) session.add(c) await session.commit() + record_product_event("connection", "create", "success") return ConnectionOut(db_connection_uuid=c.db_connection_uuid, conn_name=c.conn_name) diff --git a/backend/app/api/me.py b/backend/app/api/me.py index 45810a8b..5647015d 100644 --- a/backend/app/api/me.py +++ b/backend/app/api/me.py @@ -4,10 +4,15 @@ from app.auth import CurrentUser, get_current_user from app.schemas import MeOut +from app.settings import settings router = APIRouter(prefix="/api", tags=["me"]) +def _split_csv(value: str) -> set[str]: + return {item.strip() for item in value.split(",") if item.strip()} + + @router.get("/me", response_model=MeOut) async def get_me(user: CurrentUser = Depends(get_current_user)) -> MeOut: """Return the current user's identity.""" @@ -15,4 +20,5 @@ async def get_me(user: CurrentUser = Depends(get_current_user)) -> MeOut: user_account_uuid=user.user_account_uuid, subject=user.subject, display_name=user.display_name, + support_operator=user.subject in _split_csv(settings.support_operator_subjects), ) diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index 4d2a1c90..887b058b 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -10,6 +10,7 @@ from app.auth import CurrentUser, get_current_user from app.db import get_read_session, get_session +from app.metrics import record_product_event from app.permissions import require_project_member from app.models import ProjectMember, ProjectSpace, UserAccount from app.schemas import ( @@ -19,6 +20,7 @@ ProjectOut, ) from app.sanitize import sanitize_for_storage +from app.usage_quotas import enforce_project_quota, enforce_seat_quota router = APIRouter(prefix="/api/projects", tags=["projects"]) @@ -52,6 +54,12 @@ async def create_project( session: AsyncSession = Depends(get_session), ) -> ProjectOut: """Create a new project and add the creator as the owner.""" + try: + await enforce_project_quota(session, user.user_account_uuid) + except HTTPException: + record_product_event("project", "create", "denied") + raise + p = ProjectSpace( project_space_uuid=uuid.uuid4(), project_name=str(sanitize_for_storage(body.project_name)), @@ -69,6 +77,7 @@ async def create_project( ) session.add(m) await session.commit() + record_product_event("project", "create", "success") return ProjectOut( project_space_uuid=p.project_space_uuid, project_name=p.project_name ) @@ -121,20 +130,22 @@ async def _ensure_owner( raise HTTPException(status_code=403, detail="owner role required") -async def _ensure_user_exists(session: AsyncSession, subject: str) -> UserAccount: +async def _find_user_by_subject(session: AsyncSession, subject: str) -> UserAccount | None: row2 = await session.execute( select(UserAccount).where(UserAccount.oidc_subject == subject) ) - u = row2.scalars().first() - if u is None: - u = UserAccount( - user_account_uuid=uuid.uuid4(), - oidc_subject=subject, - display_name=None, - created_at=dt.datetime.now(dt.timezone.utc), - ) - session.add(u) - await session.flush() + return row2.scalars().first() + + +async def _create_user(session: AsyncSession, subject: str) -> UserAccount: + u = UserAccount( + user_account_uuid=uuid.uuid4(), + oidc_subject=subject, + display_name=None, + created_at=dt.datetime.now(dt.timezone.utc), + ) + session.add(u) + await session.flush() return u @@ -200,7 +211,17 @@ async def add_project_member( if not subject: raise HTTPException(status_code=400, detail="member_subject required") - u = await _ensure_user_exists(session, subject) + u = await _find_user_by_subject(session, subject) + await enforce_seat_quota( + session, + owner_user_account_uuid=user.user_account_uuid, + owner_subject=user.subject, + candidate_user_account_uuid=( + u.user_account_uuid if u is not None else None + ), + ) + if u is None: + u = await _create_user(session, subject) await _ensure_not_changing_owner_role( session, project_space_uuid, u.user_account_uuid ) diff --git a/backend/app/api/share.py b/backend/app/api/share.py index 33cc8bde..d323b136 100644 --- a/backend/app/api/share.py +++ b/backend/app/api/share.py @@ -1,16 +1,23 @@ from __future__ import annotations import datetime as dt +import json +import logging import uuid +from typing import NoReturn -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response from fastapi.responses import PlainTextResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from starlette.requests import Request from app.auth import CurrentUser, get_current_user from app.db import get_read_session, get_session from app.ddl.export import snapshot_json_to_sql +from app.llm_quota import enforce_llm_draft_quota +from app.llm_usage import record_persistent_llm_draft_usage +from app.metrics import SHARE_AUDIT_EVENTS_TOTAL, record_product_event from app.models import ( ProjectMember, SchemaSnapshot, @@ -19,63 +26,356 @@ ) from app.spec.llm import ( LlmConfigurationError, + LlmPromptTooLargeError, LlmProviderError, generate_index_design_llm_draft, generate_reversing_llm_draft, ) +from app.settings import settings +from app.schemas import ShareLinkCreateIn, ShareLinkOut from app.spec.index_design import generate_index_design_spec from app.spec.reversing import generate_reversing_spec +from app.usage_quotas import enforce_share_link_quota router = APIRouter(prefix="/api", tags=["share"]) +_LOGGER = logging.getLogger("app.share") -@router.post("/projects/{project_space_uuid}/share-links") -async def create_share_link( +def _request_client_ip(request: Request | None) -> str: + if request is None: + return "unknown" + + if settings.api_rate_limit_trust_x_forwarded_for: + xff = request.headers.get("X-Forwarded-For") + if xff: + return xff.split(",", maxsplit=1)[0].strip() or "unknown" + + client = request.client + if client is None: + return "unknown" + return client.host or "unknown" + + +def _record_share_audit_event( + action: str, + outcome: str, + *, + request: Request | None = None, + share_link_uuid: uuid.UUID | None = None, + project_space_uuid: uuid.UUID | None = None, + schema_snapshot_uuid: uuid.UUID | None = None, + user_account_uuid: uuid.UUID | None = None, + error_detail: str | None = None, + mode: str | None = None, +) -> None: + request_id = None + if request is not None: + request_state = getattr(request, "state", None) + request_id = getattr(request_state, "request_id", None) + + payload: dict[str, object] = { + "event": "share_audit", + "action": action, + "outcome": outcome, + "client_ip": _request_client_ip(request), + "user_agent": (request.headers.get("user-agent") if request else None), + "request_id": request_id, + "share_link_uuid": str(share_link_uuid) if share_link_uuid else None, + "project_space_uuid": str(project_space_uuid) if project_space_uuid else None, + "schema_snapshot_uuid": ( + str(schema_snapshot_uuid) if schema_snapshot_uuid else None + ), + "user_account_uuid": str(user_account_uuid) if user_account_uuid else None, + "mode": mode, + "error_detail": error_detail, + } + SHARE_AUDIT_EVENTS_TOTAL.labels(action=action, outcome=outcome).inc() + _LOGGER.info(json.dumps(payload, ensure_ascii=False, separators=(",", ":"))) + + +def _raise_not_found( + request: Request | None, + action: str, + share_link_uuid: uuid.UUID, + *, + project_space_uuid: uuid.UUID | None = None, + schema_snapshot_uuid: uuid.UUID | None = None, +) -> NoReturn: + _record_share_audit_event( + action=action, + outcome="not_found", + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + ) + raise HTTPException(status_code=404, detail="share link not found") + + +def _raise_expired( + request: Request | None, action: str, share_link_uuid: uuid.UUID +) -> NoReturn: + _record_share_audit_event( + action=action, + outcome="expired", + request=request, + share_link_uuid=share_link_uuid, + ) + raise HTTPException(status_code=410, detail="share link expired") + + +def _record_share_audit_error( + action: str, + outcome: str, + request: Request, + share_link_uuid: uuid.UUID, + *, + project_space_uuid: uuid.UUID | None = None, + schema_snapshot_uuid: uuid.UUID | None = None, + user_account_uuid: uuid.UUID | None = None, + error_detail: str | None = None, + mode: str | None = None, +) -> None: + _record_share_audit_event( + action=action, + outcome=outcome, + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + user_account_uuid=user_account_uuid, + error_detail=error_detail, + mode=mode, + ) + + +async def _enforce_share_link_llm_draft_quota( + *, + session: AsyncSession, + action: str, + artifact: str, + snapshot_json: dict, + request: Request, + share_link_uuid: uuid.UUID, project_space_uuid: uuid.UUID, - user: CurrentUser = Depends(get_current_user), - session: AsyncSession = Depends(get_session), -) -> dict: - """Create a share link for a project (owner-only).""" - # owner only + schema_snapshot_uuid: uuid.UUID, + mode: str, +) -> None: + try: + await enforce_llm_draft_quota(f"share:{share_link_uuid}") + except HTTPException: + await record_persistent_llm_draft_usage( + session, + surface="share_link", + artifact=artifact, + outcome="quota_exceeded", + snapshot_json=snapshot_json, + project_space_uuid=project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + share_link_uuid=share_link_uuid, + error_code="quota_exceeded", + ) + _record_share_audit_error( + action=action, + outcome="denied", + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + mode=mode, + error_detail="LLM draft quota exceeded", + ) + raise + + +def _require_share_link_llm_draft_enabled() -> None: + if not settings.share_link_llm_draft_enabled: + raise HTTPException( + status_code=403, + detail="share link LLM draft is disabled", + ) + + +async def _ensure_project_owner( + session: AsyncSession, project_space_uuid: uuid.UUID, user_account_uuid: uuid.UUID +) -> None: row = await session.execute( select(ProjectMember.project_role).where( ProjectMember.project_space_uuid == project_space_uuid, - ProjectMember.user_account_uuid == user.user_account_uuid, + ProjectMember.user_account_uuid == user_account_uuid, ) ) if row.scalar_one_or_none() != "owner": raise HTTPException(status_code=403, detail="owner role required") + +def _share_link_expiry(body: ShareLinkCreateIn | None) -> dt.datetime | None: + ttl_hours = ( + settings.share_link_default_ttl_hours + if body is None or body.expires_in_hours is None + else body.expires_in_hours + ) + if ttl_hours == 0: + return None + return dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=ttl_hours) + + +def _share_link_out(link: ShareLink) -> ShareLinkOut: + return ShareLinkOut( + share_link_uuid=link.share_link_uuid, + permission_kind=link.permission_kind, + url_path=f"/api/share/{link.share_link_uuid}", + expires_at=link.expires_at, + created_at=link.created_at, + ) + + +@router.post("/projects/{project_space_uuid}/share-links") +async def create_share_link( + project_space_uuid: uuid.UUID, + request: Request, + body: ShareLinkCreateIn | None = Body(default=None), + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> ShareLinkOut: + """Create a share link for a project (owner-only).""" + try: + await _ensure_project_owner(session, project_space_uuid, user.user_account_uuid) + await enforce_share_link_quota(session, project_space_uuid) + except HTTPException as exc: + record_product_event("share_link", "create", "denied") + _record_share_audit_event( + action="share_link.create", + outcome="denied", + request=request, + project_space_uuid=project_space_uuid, + user_account_uuid=user.user_account_uuid, + error_detail=exc.detail, + ) + raise + + now = dt.datetime.now(dt.timezone.utc) link = ShareLink( share_link_uuid=uuid.uuid4(), project_space_uuid=project_space_uuid, created_by_user_uuid=user.user_account_uuid, permission_kind="viewer", - expires_at=None, - created_at=dt.datetime.now(dt.timezone.utc), + expires_at=_share_link_expiry(body), + created_at=now, ) session.add(link) await session.commit() - return { - "share_link_uuid": str(link.share_link_uuid), - "permission_kind": link.permission_kind, - "url_path": f"/api/share/{link.share_link_uuid}", - } + record_product_event("share_link", "create", "success") + _record_share_audit_event( + action="share_link.create", + outcome="success", + request=request, + share_link_uuid=link.share_link_uuid, + project_space_uuid=project_space_uuid, + user_account_uuid=user.user_account_uuid, + ) + return _share_link_out(link) + + +@router.get("/projects/{project_space_uuid}/share-links") +async def list_share_links( + project_space_uuid: uuid.UUID, + request: Request, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_read_session), +) -> list[ShareLinkOut]: + """List share links for a project (owner-only).""" + try: + await _ensure_project_owner(session, project_space_uuid, user.user_account_uuid) + except HTTPException as exc: + _record_share_audit_event( + action="share_link.list", + outcome="denied", + request=request, + project_space_uuid=project_space_uuid, + user_account_uuid=user.user_account_uuid, + error_detail=exc.detail, + ) + raise + + rows = await session.execute( + select(ShareLink) + .where(ShareLink.project_space_uuid == project_space_uuid) + .order_by(ShareLink.created_at.desc()) + ) + links = [_share_link_out(link) for link in rows.scalars().all()] + _record_share_audit_event( + action="share_link.list", + outcome="success", + request=request, + project_space_uuid=project_space_uuid, + user_account_uuid=user.user_account_uuid, + ) + return links + + +@router.delete("/projects/{project_space_uuid}/share-links/{share_link_uuid}") +async def delete_share_link( + project_space_uuid: uuid.UUID, + share_link_uuid: uuid.UUID, + request: Request, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> Response: + """Revoke a project share link by deleting it (owner-only).""" + try: + await _ensure_project_owner(session, project_space_uuid, user.user_account_uuid) + except HTTPException as exc: + _record_share_audit_event( + action="share_link.delete", + outcome="denied", + request=request, + project_space_uuid=project_space_uuid, + share_link_uuid=share_link_uuid, + user_account_uuid=user.user_account_uuid, + error_detail=exc.detail, + ) + raise + + link = await session.get(ShareLink, share_link_uuid) + if link is None or link.project_space_uuid != project_space_uuid: + _record_share_audit_event( + action="share_link.delete", + outcome="not_found", + request=request, + project_space_uuid=project_space_uuid, + share_link_uuid=share_link_uuid, + user_account_uuid=user.user_account_uuid, + ) + raise HTTPException(status_code=404, detail="share link not found") + + await session.delete(link) + await session.commit() + _record_share_audit_event( + action="share_link.delete", + outcome="success", + request=request, + project_space_uuid=project_space_uuid, + share_link_uuid=share_link_uuid, + user_account_uuid=user.user_account_uuid, + ) + return Response(status_code=204) @router.get("/share/{share_link_uuid}") async def get_share_link_info( share_link_uuid: uuid.UUID, + request: Request, session: AsyncSession = Depends(get_read_session), ) -> dict: """Return share link metadata and recent snapshots.""" link = await session.get(ShareLink, share_link_uuid) if link is None: - raise HTTPException(status_code=404, detail="share link not found") + _raise_not_found(request, "share_link.info", share_link_uuid) if link.expires_at is not None and link.expires_at <= dt.datetime.now( dt.timezone.utc ): - raise HTTPException(status_code=410, detail="share link expired") + _raise_expired(request, "share_link.info", share_link_uuid) rows = await session.execute( select(SchemaSnapshot) @@ -84,6 +384,13 @@ async def get_share_link_info( .limit(20) ) snaps = rows.scalars().all() + _record_share_audit_event( + action="share_link.info", + outcome="success", + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=link.project_space_uuid, + ) return { "project_space_uuid": str(link.project_space_uuid), "permission_kind": link.permission_kind, @@ -103,22 +410,45 @@ async def get_share_link_info( async def get_shared_snapshot( share_link_uuid: uuid.UUID, schema_snapshot_uuid: uuid.UUID, + request: Request, session: AsyncSession = Depends(get_read_session), ) -> dict: """Return a snapshot via a share link (no auth).""" link = await session.get(ShareLink, share_link_uuid) if link is None: - raise HTTPException(status_code=404, detail="share link not found") + _raise_not_found( + request, + "share_snapshot.get", + share_link_uuid, + project_space_uuid=None, + schema_snapshot_uuid=schema_snapshot_uuid, + ) if link.expires_at is not None and link.expires_at <= dt.datetime.now( dt.timezone.utc ): - raise HTTPException(status_code=410, detail="share link expired") + _raise_expired(request, "share_snapshot.get", share_link_uuid) snap = await session.get(SchemaSnapshot, schema_snapshot_uuid) if snap is None or snap.project_space_uuid != link.project_space_uuid: + _record_share_audit_event( + action="share_snapshot.get", + outcome="not_found", + request=request, + share_link_uuid=share_link_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + project_space_uuid=link.project_space_uuid, + ) raise HTTPException(status_code=404, detail="snapshot not found") data = await session.get(SchemaSnapshotData, schema_snapshot_uuid) + _record_share_audit_event( + action="share_snapshot.get", + outcome="success", + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + ) return { "schema_snapshot_uuid": str(snap.schema_snapshot_uuid), "status": snap.status, @@ -135,25 +465,55 @@ async def get_shared_snapshot( async def export_shared_snapshot_sql( share_link_uuid: uuid.UUID, schema_snapshot_uuid: uuid.UUID, + request: Request, dialect: str = Query("postgresql", pattern="^(postgresql|snowflake)$"), session: AsyncSession = Depends(get_read_session), ) -> str: """Export a shared snapshot as SQL via a share link.""" link = await session.get(ShareLink, share_link_uuid) if link is None: - raise HTTPException(status_code=404, detail="share link not found") + _raise_not_found( + request, + "share_snapshot.export_sql", + share_link_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + ) if link.expires_at is not None and link.expires_at <= dt.datetime.now( dt.timezone.utc ): - raise HTTPException(status_code=410, detail="share link expired") + _raise_expired(request, "share_snapshot.export_sql", share_link_uuid) snap = await session.get(SchemaSnapshot, schema_snapshot_uuid) if snap is None or snap.project_space_uuid != link.project_space_uuid: + _record_share_audit_event( + action="share_snapshot.export_sql", + outcome="not_found", + request=request, + share_link_uuid=share_link_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + project_space_uuid=link.project_space_uuid, + ) raise HTTPException(status_code=404, detail="snapshot not found") data = await session.get(SchemaSnapshotData, schema_snapshot_uuid) if data is None: + _record_share_audit_event( + action="share_snapshot.export_sql", + outcome="not_found", + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + ) return "-- snapshot data not found\n" + _record_share_audit_event( + action="share_snapshot.export_sql", + outcome="success", + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + ) return snapshot_json_to_sql(data.snapshot_json, target_dialect=dialect) @@ -164,36 +524,188 @@ async def export_shared_snapshot_sql( async def export_shared_snapshot_reversing_spec( share_link_uuid: uuid.UUID, schema_snapshot_uuid: uuid.UUID, + request: Request, mode: str = Query("markdown", pattern="^(markdown|llm-prompt|llm-draft)$"), - session: AsyncSession = Depends(get_read_session), + session: AsyncSession = Depends(get_session), ) -> str: """Export a shared snapshot as a DB reversing spec or LLM prompt.""" link = await session.get(ShareLink, share_link_uuid) if link is None: - raise HTTPException(status_code=404, detail="share link not found") + _raise_not_found( + request, + "share_snapshot.reversing_spec", + share_link_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + ) if link.expires_at is not None and link.expires_at <= dt.datetime.now( dt.timezone.utc ): - raise HTTPException(status_code=410, detail="share link expired") + _raise_expired(request, "share_snapshot.reversing_spec", share_link_uuid) snap = await session.get(SchemaSnapshot, schema_snapshot_uuid) if snap is None or snap.project_space_uuid != link.project_space_uuid: + _record_share_audit_event( + action="share_snapshot.reversing_spec", + outcome="not_found", + request=request, + share_link_uuid=share_link_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + project_space_uuid=link.project_space_uuid, + ) raise HTTPException(status_code=404, detail="snapshot not found") data = await session.get(SchemaSnapshotData, schema_snapshot_uuid) if data is None: + _record_share_audit_event( + action="share_snapshot.reversing_spec", + outcome="not_found", + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + mode=mode, + ) return "# DB Reversing Specification\n\nSnapshot data not found.\n" if mode == "llm-draft": + if not settings.share_link_llm_draft_enabled: + await record_persistent_llm_draft_usage( + session, + surface="share_link", + artifact="reversing_spec", + outcome="disabled", + snapshot_json=data.snapshot_json, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + share_link_uuid=share_link_uuid, + error_code="disabled", + ) + _record_share_audit_error( + action="share_snapshot.reversing_spec", + outcome="denied", + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + mode=mode, + error_detail="share link LLM draft is disabled", + ) + _require_share_link_llm_draft_enabled() + await _enforce_share_link_llm_draft_quota( + session=session, + action="share_snapshot.reversing_spec", + artifact="reversing_spec", + snapshot_json=data.snapshot_json, + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + mode=mode, + ) try: - return await generate_reversing_llm_draft(data.snapshot_json) + draft = await generate_reversing_llm_draft(data.snapshot_json) except LlmConfigurationError as exc: + await record_persistent_llm_draft_usage( + session, + surface="share_link", + artifact="reversing_spec", + outcome="configuration_error", + snapshot_json=data.snapshot_json, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + share_link_uuid=share_link_uuid, + error_code="configuration_error", + ) + _record_share_audit_error( + action="share_snapshot.reversing_spec", + outcome="failed", + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + mode=mode, + error_detail="LLM configuration error", + ) raise HTTPException( status_code=503, detail="LLM configuration error" ) from exc + except LlmPromptTooLargeError as exc: + await record_persistent_llm_draft_usage( + session, + surface="share_link", + artifact="reversing_spec", + outcome="prompt_too_large", + snapshot_json=data.snapshot_json, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + share_link_uuid=share_link_uuid, + error_code="prompt_too_large", + ) + _record_share_audit_error( + action="share_snapshot.reversing_spec", + outcome="failed", + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + mode=mode, + error_detail="LLM prompt too large", + ) + raise HTTPException(status_code=413, detail="LLM prompt too large") from exc except LlmProviderError as exc: + await record_persistent_llm_draft_usage( + session, + surface="share_link", + artifact="reversing_spec", + outcome="provider_error", + snapshot_json=data.snapshot_json, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + share_link_uuid=share_link_uuid, + error_code="provider_error", + ) + _record_share_audit_error( + action="share_snapshot.reversing_spec", + outcome="failed", + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + mode=mode, + error_detail="LLM provider request failed", + ) raise HTTPException( status_code=502, detail="LLM provider request failed" ) from exc + await record_persistent_llm_draft_usage( + session, + surface="share_link", + artifact="reversing_spec", + outcome="success", + snapshot_json=data.snapshot_json, + output_text=draft, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + share_link_uuid=share_link_uuid, + ) + _record_share_audit_event( + action="share_snapshot.reversing_spec", + outcome="success", + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + mode=mode, + ) + return draft + _record_share_audit_event( + action="share_snapshot.reversing_spec", + outcome="success", + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + mode=mode, + ) return generate_reversing_spec(data.snapshot_json, mode=mode) @@ -204,34 +716,186 @@ async def export_shared_snapshot_reversing_spec( async def export_shared_snapshot_index_design( share_link_uuid: uuid.UUID, schema_snapshot_uuid: uuid.UUID, + request: Request, mode: str = Query("markdown", pattern="^(markdown|llm-prompt|llm-draft)$"), - session: AsyncSession = Depends(get_read_session), + session: AsyncSession = Depends(get_session), ) -> str: """Export shared table/index design guidance or an LLM prompt.""" link = await session.get(ShareLink, share_link_uuid) if link is None: - raise HTTPException(status_code=404, detail="share link not found") + _raise_not_found( + request, + "share_snapshot.index_design", + share_link_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + ) if link.expires_at is not None and link.expires_at <= dt.datetime.now( dt.timezone.utc ): - raise HTTPException(status_code=410, detail="share link expired") + _raise_expired(request, "share_snapshot.index_design", share_link_uuid) snap = await session.get(SchemaSnapshot, schema_snapshot_uuid) if snap is None or snap.project_space_uuid != link.project_space_uuid: + _record_share_audit_event( + action="share_snapshot.index_design", + outcome="not_found", + request=request, + share_link_uuid=share_link_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + project_space_uuid=link.project_space_uuid, + ) raise HTTPException(status_code=404, detail="snapshot not found") data = await session.get(SchemaSnapshotData, schema_snapshot_uuid) if data is None: + _record_share_audit_event( + action="share_snapshot.index_design", + outcome="not_found", + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + mode=mode, + ) return "# ERD Index Design\n\nSnapshot data not found.\n" if mode == "llm-draft": + if not settings.share_link_llm_draft_enabled: + await record_persistent_llm_draft_usage( + session, + surface="share_link", + artifact="index_design", + outcome="disabled", + snapshot_json=data.snapshot_json, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + share_link_uuid=share_link_uuid, + error_code="disabled", + ) + _record_share_audit_error( + action="share_snapshot.index_design", + outcome="denied", + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + mode=mode, + error_detail="share link LLM draft is disabled", + ) + _require_share_link_llm_draft_enabled() + await _enforce_share_link_llm_draft_quota( + session=session, + action="share_snapshot.index_design", + artifact="index_design", + snapshot_json=data.snapshot_json, + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + mode=mode, + ) try: - return await generate_index_design_llm_draft(data.snapshot_json) + draft = await generate_index_design_llm_draft(data.snapshot_json) except LlmConfigurationError as exc: + await record_persistent_llm_draft_usage( + session, + surface="share_link", + artifact="index_design", + outcome="configuration_error", + snapshot_json=data.snapshot_json, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + share_link_uuid=share_link_uuid, + error_code="configuration_error", + ) + _record_share_audit_error( + action="share_snapshot.index_design", + outcome="failed", + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + mode=mode, + error_detail="LLM configuration error", + ) raise HTTPException( status_code=503, detail="LLM configuration error" ) from exc + except LlmPromptTooLargeError as exc: + await record_persistent_llm_draft_usage( + session, + surface="share_link", + artifact="index_design", + outcome="prompt_too_large", + snapshot_json=data.snapshot_json, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + share_link_uuid=share_link_uuid, + error_code="prompt_too_large", + ) + _record_share_audit_error( + action="share_snapshot.index_design", + outcome="failed", + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + mode=mode, + error_detail="LLM prompt too large", + ) + raise HTTPException(status_code=413, detail="LLM prompt too large") from exc except LlmProviderError as exc: + await record_persistent_llm_draft_usage( + session, + surface="share_link", + artifact="index_design", + outcome="provider_error", + snapshot_json=data.snapshot_json, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + share_link_uuid=share_link_uuid, + error_code="provider_error", + ) + _record_share_audit_error( + action="share_snapshot.index_design", + outcome="failed", + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + mode=mode, + error_detail="LLM provider request failed", + ) raise HTTPException( status_code=502, detail="LLM provider request failed" ) from exc + await record_persistent_llm_draft_usage( + session, + surface="share_link", + artifact="index_design", + outcome="success", + snapshot_json=data.snapshot_json, + output_text=draft, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + share_link_uuid=share_link_uuid, + ) + _record_share_audit_event( + action="share_snapshot.index_design", + outcome="success", + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + mode=mode, + ) + return draft + _record_share_audit_event( + action="share_snapshot.index_design", + outcome="success", + request=request, + share_link_uuid=share_link_uuid, + project_space_uuid=link.project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + mode=mode, + ) return generate_index_design_spec(data.snapshot_json, mode=mode) diff --git a/backend/app/api/snapshots.py b/backend/app/api/snapshots.py index 79f2515f..9c908566 100644 --- a/backend/app/api/snapshots.py +++ b/backend/app/api/snapshots.py @@ -10,6 +10,9 @@ from app.auth import CurrentUser, get_current_user from app.db import get_read_session, get_session +from app.llm_quota import enforce_llm_draft_quota +from app.llm_usage import record_persistent_llm_draft_usage +from app.metrics import record_product_event from app.models import ( DbConnection, JobQueue, @@ -22,12 +25,14 @@ from app.jobs.valkey_queue import enqueue_job_signal from app.spec.llm import ( LlmConfigurationError, + LlmPromptTooLargeError, LlmProviderError, generate_index_design_llm_draft, generate_reversing_llm_draft, ) from app.spec.index_design import generate_index_design_spec from app.spec.reversing import generate_reversing_spec +from app.usage_quotas import enforce_snapshot_quota router = APIRouter(prefix="/api/snapshots", tags=["snapshots"]) @@ -44,6 +49,10 @@ def _snapshot_not_found(schema_snapshot_uuid: uuid.UUID) -> SnapshotDetailOut: ) +def _snapshot_project_space_uuid(snap: SchemaSnapshot) -> uuid.UUID | None: + return getattr(snap, "project_space_uuid", None) + + async def _get_authorized_snapshot( session: AsyncSession, schema_snapshot_uuid: uuid.UUID, @@ -71,6 +80,33 @@ async def _get_authorized_snapshot( return await session.get(SchemaSnapshot, schema_snapshot_uuid) +async def _enforce_authenticated_llm_draft_quota( + *, + session: AsyncSession, + artifact: str, + snapshot_json: dict, + user: CurrentUser, + snap: SchemaSnapshot, + schema_snapshot_uuid: uuid.UUID, +) -> None: + try: + await enforce_llm_draft_quota(f"account:{user.user_account_uuid}") + except HTTPException: + await record_persistent_llm_draft_usage( + session, + surface="authenticated", + artifact=artifact, + outcome="quota_exceeded", + snapshot_json=snapshot_json, + subject=user.subject, + user_account_uuid=user.user_account_uuid, + project_space_uuid=_snapshot_project_space_uuid(snap), + schema_snapshot_uuid=schema_snapshot_uuid, + error_code="quota_exceeded", + ) + raise + + @router.post("/by-project/{project_space_uuid}", response_model=SnapshotOut) async def create_snapshot( project_space_uuid: uuid.UUID, @@ -79,14 +115,24 @@ async def create_snapshot( session: AsyncSession = Depends(get_session), ) -> SnapshotOut: """Create a schema snapshot job for a project connection.""" - await require_project_member( - session, project_space_uuid, user.user_account_uuid, minimum_role="editor" - ) + try: + await require_project_member( + session, project_space_uuid, user.user_account_uuid, minimum_role="editor" + ) + except HTTPException: + record_product_event("snapshot", "create", "denied") + raise # Ensure connection belongs to this project conn = await session.get(DbConnection, body.db_connection_uuid) if conn is None or conn.project_space_uuid != project_space_uuid: + record_product_event("snapshot", "create", "not_found") raise HTTPException(status_code=404, detail="connection not found") + try: + await enforce_snapshot_quota(session, project_space_uuid) + except HTTPException: + record_product_event("snapshot", "create", "denied") + raise snap = SchemaSnapshot( schema_snapshot_uuid=uuid.uuid4(), @@ -117,6 +163,7 @@ async def create_snapshot( await session.commit() await enqueue_job_signal(job.job_queue_uuid, job.run_after) + record_product_event("snapshot", "create", "queued") return SnapshotOut( schema_snapshot_uuid=snap.schema_snapshot_uuid, status=snap.status, @@ -169,7 +216,7 @@ async def export_snapshot_reversing_spec( schema_snapshot_uuid: uuid.UUID, mode: str = Query("markdown", pattern="^(markdown|llm-prompt|llm-draft)$"), user: CurrentUser = Depends(get_current_user), - session: AsyncSession = Depends(get_read_session), + session: AsyncSession = Depends(get_session), ) -> str: """Export a snapshot as a DB reversing spec or LLM prompt.""" snap = await _get_authorized_snapshot(session, schema_snapshot_uuid, user) @@ -179,16 +226,75 @@ async def export_snapshot_reversing_spec( if data is None: return "# DB Reversing Specification\n\nSnapshot data not found.\n" if mode == "llm-draft": + await _enforce_authenticated_llm_draft_quota( + session=session, + artifact="reversing_spec", + snapshot_json=data.snapshot_json, + user=user, + snap=snap, + schema_snapshot_uuid=schema_snapshot_uuid, + ) try: - return await generate_reversing_llm_draft(data.snapshot_json) + draft = await generate_reversing_llm_draft(data.snapshot_json) except LlmConfigurationError as exc: + await record_persistent_llm_draft_usage( + session, + surface="authenticated", + artifact="reversing_spec", + outcome="configuration_error", + snapshot_json=data.snapshot_json, + subject=user.subject, + user_account_uuid=user.user_account_uuid, + project_space_uuid=_snapshot_project_space_uuid(snap), + schema_snapshot_uuid=schema_snapshot_uuid, + error_code="configuration_error", + ) raise HTTPException( status_code=503, detail="LLM configuration error" ) from exc + except LlmPromptTooLargeError as exc: + await record_persistent_llm_draft_usage( + session, + surface="authenticated", + artifact="reversing_spec", + outcome="prompt_too_large", + snapshot_json=data.snapshot_json, + subject=user.subject, + user_account_uuid=user.user_account_uuid, + project_space_uuid=_snapshot_project_space_uuid(snap), + schema_snapshot_uuid=schema_snapshot_uuid, + error_code="prompt_too_large", + ) + raise HTTPException(status_code=413, detail="LLM prompt too large") from exc except LlmProviderError as exc: + await record_persistent_llm_draft_usage( + session, + surface="authenticated", + artifact="reversing_spec", + outcome="provider_error", + snapshot_json=data.snapshot_json, + subject=user.subject, + user_account_uuid=user.user_account_uuid, + project_space_uuid=_snapshot_project_space_uuid(snap), + schema_snapshot_uuid=schema_snapshot_uuid, + error_code="provider_error", + ) raise HTTPException( status_code=502, detail="LLM provider request failed" ) from exc + await record_persistent_llm_draft_usage( + session, + surface="authenticated", + artifact="reversing_spec", + outcome="success", + snapshot_json=data.snapshot_json, + output_text=draft, + subject=user.subject, + user_account_uuid=user.user_account_uuid, + project_space_uuid=_snapshot_project_space_uuid(snap), + schema_snapshot_uuid=schema_snapshot_uuid, + ) + return draft return generate_reversing_spec(data.snapshot_json, mode=mode) @@ -200,7 +306,7 @@ async def export_snapshot_index_design( schema_snapshot_uuid: uuid.UUID, mode: str = Query("markdown", pattern="^(markdown|llm-prompt|llm-draft)$"), user: CurrentUser = Depends(get_current_user), - session: AsyncSession = Depends(get_read_session), + session: AsyncSession = Depends(get_session), ) -> str: """Export table/index design guidance or an LLM prompt.""" snap = await _get_authorized_snapshot(session, schema_snapshot_uuid, user) @@ -210,16 +316,75 @@ async def export_snapshot_index_design( if data is None: return "# ERD Index Design\n\nSnapshot data not found.\n" if mode == "llm-draft": + await _enforce_authenticated_llm_draft_quota( + session=session, + artifact="index_design", + snapshot_json=data.snapshot_json, + user=user, + snap=snap, + schema_snapshot_uuid=schema_snapshot_uuid, + ) try: - return await generate_index_design_llm_draft(data.snapshot_json) + draft = await generate_index_design_llm_draft(data.snapshot_json) except LlmConfigurationError as exc: + await record_persistent_llm_draft_usage( + session, + surface="authenticated", + artifact="index_design", + outcome="configuration_error", + snapshot_json=data.snapshot_json, + subject=user.subject, + user_account_uuid=user.user_account_uuid, + project_space_uuid=_snapshot_project_space_uuid(snap), + schema_snapshot_uuid=schema_snapshot_uuid, + error_code="configuration_error", + ) raise HTTPException( status_code=503, detail="LLM configuration error" ) from exc + except LlmPromptTooLargeError as exc: + await record_persistent_llm_draft_usage( + session, + surface="authenticated", + artifact="index_design", + outcome="prompt_too_large", + snapshot_json=data.snapshot_json, + subject=user.subject, + user_account_uuid=user.user_account_uuid, + project_space_uuid=_snapshot_project_space_uuid(snap), + schema_snapshot_uuid=schema_snapshot_uuid, + error_code="prompt_too_large", + ) + raise HTTPException(status_code=413, detail="LLM prompt too large") from exc except LlmProviderError as exc: + await record_persistent_llm_draft_usage( + session, + surface="authenticated", + artifact="index_design", + outcome="provider_error", + snapshot_json=data.snapshot_json, + subject=user.subject, + user_account_uuid=user.user_account_uuid, + project_space_uuid=_snapshot_project_space_uuid(snap), + schema_snapshot_uuid=schema_snapshot_uuid, + error_code="provider_error", + ) raise HTTPException( status_code=502, detail="LLM provider request failed" ) from exc + await record_persistent_llm_draft_usage( + session, + surface="authenticated", + artifact="index_design", + outcome="success", + snapshot_json=data.snapshot_json, + output_text=draft, + subject=user.subject, + user_account_uuid=user.user_account_uuid, + project_space_uuid=_snapshot_project_space_uuid(snap), + schema_snapshot_uuid=schema_snapshot_uuid, + ) + return draft return generate_index_design_spec(data.snapshot_json, mode=mode) diff --git a/backend/app/auth.py b/backend/app/auth.py index 132e57f3..9f868b10 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -12,6 +12,7 @@ from sqlalchemy import select, delete from sqlalchemy.ext.asyncio import AsyncSession +from app.contract_state import latest_contract_state_for_subject from app.db import get_session from app.models import UserAccount from app.settings import settings @@ -47,6 +48,40 @@ def _parse_oidc_algorithms(raw: str) -> list[str]: return normalized or ["RS256"] +def _split_csv(raw: str) -> set[str]: + return {item.strip() for item in raw.split(",") if item.strip()} + + +def _account_deactivation_headers() -> dict[str, str]: + headers = {"X-Account-Status": "deactivated"} + if settings.account_reactivation_url: + headers["X-Account-Reactivation-Url"] = settings.account_reactivation_url + if settings.billing_support_url: + headers["X-Billing-Support-Url"] = settings.billing_support_url + return headers + + +def _reject_deactivated_subject(subject: str) -> None: + if subject in _split_csv(settings.account_deactivated_subjects): + raise HTTPException( + status_code=403, + detail="account deactivated", + headers=_account_deactivation_headers(), + ) + + +async def _reject_contract_deactivated_subject( + session: AsyncSession, + subject: str, +) -> None: + if await latest_contract_state_for_subject(session, subject) == "deactivated": + raise HTTPException( + status_code=403, + detail="account deactivated", + headers=_account_deactivation_headers(), + ) + + @dataclass(frozen=True) class CurrentUser: """Authenticated user identity used by API handlers.""" @@ -412,7 +447,9 @@ async def get_current_user( ) -> CurrentUser: """FastAPI dependency that authenticates and returns the current user.""" subject, display_name = await _get_subject_from_request(request) + _reject_deactivated_subject(subject) async with session.begin(): + await _reject_contract_deactivated_subject(session, subject) return await _ensure_user(session, subject, display_name) diff --git a/backend/app/billing_entitlements.py b/backend/app/billing_entitlements.py new file mode 100644 index 00000000..6e76c3f3 --- /dev/null +++ b/backend/app/billing_entitlements.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from sqlalchemy import desc, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import BillingEvent +from app.schemas import BillingEntitlementOut +from app.settings import settings + +_ENTITLEMENT_SEAT_METADATA_KEYS = ( + "seat_count", + "seats", + "seat_limit", + "licensed_seats", + "quantity", +) + + +def _split_csv(value: str) -> set[str]: + return {item.strip() for item in value.split(",") if item.strip()} + + +def _positive_int_metadata_value(value: object) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int) and value > 0: + return value + if isinstance(value, str) and value.isdecimal(): + parsed = int(value) + return parsed if parsed > 0 else None + return None + + +def _entitlement_seat_count(metadata: object) -> int | None: + if not isinstance(metadata, Mapping): + return None + for key in _ENTITLEMENT_SEAT_METADATA_KEYS: + if key in metadata: + parsed = _positive_int_metadata_value(metadata[key]) + if parsed is not None: + return parsed + return None + + +def empty_billing_entitlement() -> BillingEntitlementOut: + return BillingEntitlementOut( + plan=None, + seat_count=None, + source_provider=None, + source_provider_event_id=None, + source_event_type=None, + source_occurred_at=None, + ) + + +def billing_entitlement_from_events( + events: list[BillingEvent], +) -> BillingEntitlementOut: + entitlement_event_types = _split_csv(settings.billing_entitlement_event_types) + if not entitlement_event_types: + return empty_billing_entitlement() + + for event in sorted( + events, + key=lambda item: (item.occurred_at, item.received_at), + reverse=True, + ): + if event.event_type not in entitlement_event_types or not event.target_plan: + continue + return BillingEntitlementOut( + plan=event.target_plan, + seat_count=_entitlement_seat_count(event.metadata_json), + source_provider=event.provider, + source_provider_event_id=event.provider_event_id, + source_event_type=event.event_type, + source_occurred_at=event.occurred_at, + ) + + return empty_billing_entitlement() + + +async def latest_billing_entitlement_for_subject( + session: AsyncSession, + subject: str, +) -> BillingEntitlementOut: + result = await session.execute( + select(BillingEvent) + .where(BillingEvent.subject == subject) + .order_by(desc(BillingEvent.received_at)) + .limit(10) + ) + return billing_entitlement_from_events(list(result.scalars().all())) diff --git a/backend/app/contract_state.py b/backend/app/contract_state.py new file mode 100644 index 00000000..cebf8d72 --- /dev/null +++ b/backend/app/contract_state.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from typing import Literal + +from sqlalchemy import desc, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import BillingEvent +from app.settings import settings + +ContractState = Literal["active", "deactivated"] + + +def _split_csv(value: str) -> set[str]: + return {item.strip() for item in value.split(",") if item.strip()} + + +def _split_csv_items(value: str) -> list[str]: + return [item.strip() for item in value.split(",") if item.strip()] + + +def event_type_aliases() -> dict[str, str]: + aliases: dict[str, str] = {} + for item in _split_csv_items(settings.billing_event_type_aliases): + source, _, target = item.partition("=") + source = source.strip() + target = target.strip() + if source and target: + aliases[source] = target + return aliases + + +def normalize_billing_event_type( + event_type: str, + *, + provider: str | None = None, +) -> str: + aliases = event_type_aliases() + if provider: + provider_alias = aliases.get(f"{provider}:{event_type}") + if provider_alias is not None: + return provider_alias + return aliases.get(event_type, event_type) + + +def contract_state_event_types() -> set[str]: + return _split_csv(settings.billing_contract_active_event_types) | _split_csv( + settings.billing_contract_deactivated_event_types + ) + + +def contract_state_for_event_type(event_type: str) -> ContractState | None: + if event_type in _split_csv(settings.billing_contract_deactivated_event_types): + return "deactivated" + if event_type in _split_csv(settings.billing_contract_active_event_types): + return "active" + return None + + +async def latest_contract_state_for_subject( + session: AsyncSession, + subject: str, +) -> ContractState | None: + if not settings.billing_contract_state_events_enabled: + return None + + event_types = contract_state_event_types() + if not event_types: + return None + + result = await session.execute( + select(BillingEvent) + .where( + BillingEvent.subject == subject, + BillingEvent.event_type.in_(event_types), + ) + .order_by(desc(BillingEvent.occurred_at), desc(BillingEvent.received_at)) + .limit(1) + ) + event = result.scalar_one_or_none() + if event is None: + return None + return contract_state_for_event_type(event.event_type) diff --git a/backend/app/ddl/export.py b/backend/app/ddl/export.py index 11fdb993..d356b566 100644 --- a/backend/app/ddl/export.py +++ b/backend/app/ddl/export.py @@ -422,7 +422,8 @@ def _render_table_pg( if kind == "p" and not isinstance(partition_key, str): lines.append( - f"-- NOTE: {_qname(schema, name)} is partitioned; partition definition not included in MVP export" + f"-- NOTE: {_qname(schema, name)} is partitioned; " + "partition definition was not available in this snapshot export" ) col_defs = _render_table_columns_pg(oid, cols_by_oid, source_dialect) @@ -446,13 +447,14 @@ def _render_table_pg( def _snapshot_json_to_postgresql_sql(snapshot: dict) -> str: """Generate PostgreSQL DDL from a captured snapshot. - This is MVP-grade forward engineering (export): + This is conservative forward engineering (export): - Creates schemas and tables (columns only) - Adds PK/UNIQUE/CHECK inside CREATE TABLE - Adds FKs after all tables (order-safe) - Adds indexes using saved pg_get_indexdef output - Limitations (intentional, MVP): partitioning clauses and some table options are not reconstructed. + Limitations: partitioning clauses and some table options are reconstructed + only when present in the snapshot. """ relations = snapshot.get("relations", []) @@ -467,7 +469,7 @@ def _snapshot_json_to_postgresql_sql(snapshot: dict) -> str: constraints_by_oid = _group_by_relation(constraints) lines: list[str] = [] - lines.append("-- Generated by pg-erd-cloud (MVP)\n") + lines.append("-- Generated by pg-erd-cloud\n") _render_schemas(tables, lines) # CREATE TABLE + inline constraints (PK/UNIQUE/CHECK) @@ -572,7 +574,7 @@ def _snapshot_json_to_snowflake_sql(snapshot: dict) -> str: constraints_by_oid = _group_by_relation(constraints) lines: list[str] = [] - lines.append("-- Generated by pg-erd-cloud (MVP) for Snowflake\n") + lines.append("-- Generated by pg-erd-cloud for Snowflake\n") _render_schemas(tables, lines) for t in tables: diff --git a/backend/app/dsn_redaction.py b/backend/app/dsn_redaction.py index 3342c3ae..ee945b07 100644 --- a/backend/app/dsn_redaction.py +++ b/backend/app/dsn_redaction.py @@ -18,7 +18,14 @@ def _password_candidates_from_dsn(dsn: str) -> set[str]: candidates: set[str] = set() + parsed = urlsplit(dsn) + if not parsed.scheme and "://" in dsn: + # Python's urlsplit fails to parse schemes with underscores (e.g. snowflake_invalid://) + # falling back to parsing the whole string as a path. + # To fix this, we substitute the scheme with a known-safe one just for parsing secrets. + dsn_patched = re.sub(r"^[^:]+://", "http://", dsn) + parsed = urlsplit(dsn_patched) if parsed.password: candidates.add(parsed.password) diff --git a/backend/app/license_gate.py b/backend/app/license_gate.py new file mode 100644 index 00000000..0fef5e68 --- /dev/null +++ b/backend/app/license_gate.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import base64 +import binascii +import hmac +import json +import time +from typing import Any + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from cryptography.hazmat.primitives.serialization import load_pem_public_key +from fastapi import Header, HTTPException + +from app.settings import settings + +LICENSE_HEADER_NAME = "X-LICENSE-KEY" +LICENSE_TOKEN_VERSION = "v1" +LICENSE_TOKEN_MAX_LENGTH = 16_384 + + +class LicenseConfigurationError(ValueError): + """Raised when required-mode licensing is misconfigured.""" + + +class LicenseValidationError(ValueError): + """Raised when a provided license token is not valid for this deployment.""" + + +def _license_required() -> bool: + """Return whether license enforcement is currently active.""" + return settings.license_mode == "required" + + +def _normalize_key(value: str | None) -> str: + return value.strip() if value else "" + + +def _split_csv(value: str) -> set[str]: + return {item.strip() for item in value.split(",") if item.strip()} + + +def _b64url_decode(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + try: + return base64.b64decode( + (value + padding).encode("ascii"), + altchars=b"-_", + validate=True, + ) + except (binascii.Error, UnicodeEncodeError, ValueError) as exc: + raise LicenseValidationError("license token payload is invalid") from exc + + +def _load_ed25519_public_key(value: str | None) -> Ed25519PublicKey: + key_text = (value or "").strip().replace("\\n", "\n") + if not key_text: + raise LicenseConfigurationError( + "license verification key is not configured for required mode" + ) + + if "-----BEGIN" in key_text: + try: + loaded_key = load_pem_public_key(key_text.encode("utf-8")) + except ValueError as exc: + raise LicenseConfigurationError( + "license verification key must be an Ed25519 public key" + ) from exc + if not isinstance(loaded_key, Ed25519PublicKey): + raise LicenseConfigurationError( + "license verification key must be an Ed25519 public key" + ) + return loaded_key + + try: + raw_key = _b64url_decode(key_text) + except LicenseValidationError as exc: + raise LicenseConfigurationError( + "license verification key must be an Ed25519 public key" + ) from exc + try: + return Ed25519PublicKey.from_public_bytes(raw_key) + except ValueError as exc: + raise LicenseConfigurationError( + "license verification key must be an Ed25519 public key" + ) from exc + + +def _require_str_claim(payload: dict[str, Any], claim: str) -> None: + value = payload.get(claim) + if not isinstance(value, str) or not value.strip() or value != value.strip(): + raise LicenseValidationError("license token payload is invalid") + + +def _require_positive_int_claim(payload: dict[str, Any], claim: str) -> int: + value = payload.get(claim) + if not isinstance(value, int) or value <= 0: + raise LicenseValidationError("license token payload is invalid") + return value + + +def _validate_license_payload(payload: dict[str, Any], *, now: int) -> None: + _require_str_claim(payload, "sub") + _require_str_claim(payload, "plan") + + expires_at = _require_positive_int_claim(payload, "exp") + if expires_at <= now: + raise LicenseValidationError("license token is expired") + + not_before = payload.get("nbf") + if not_before is not None: + if not isinstance(not_before, int): + raise LicenseValidationError("license token payload is invalid") + if not_before > now: + raise LicenseValidationError("license token is not yet valid") + + seats = payload.get("seats") + if seats is not None and (not isinstance(seats, int) or seats <= 0): + raise LicenseValidationError("license token payload is invalid") + + license_id = payload.get("jti") + if license_id is not None and ( + not isinstance(license_id, str) + or not license_id.strip() + or license_id != license_id.strip() + ): + raise LicenseValidationError("license token payload is invalid") + + +def _reject_revoked_license(payload: dict[str, Any]) -> None: + revoked_ids = _split_csv(settings.license_revoked_token_ids) + revoked_subjects = _split_csv(settings.license_revoked_subjects) + + license_id = payload.get("jti") + if isinstance(license_id, str) and license_id in revoked_ids: + raise LicenseValidationError("license token is revoked") + + subject = payload.get("sub") + if isinstance(subject, str) and subject in revoked_subjects: + raise LicenseValidationError("license token is revoked") + + +def _validate_signed_license_token( + token: str, + public_key_value: str | None, + *, + now: int | None = None, +) -> None: + if len(token) > LICENSE_TOKEN_MAX_LENGTH: + raise LicenseValidationError("license token payload is invalid") + + parts = token.split(".") + if len(parts) != 3 or parts[0] != LICENSE_TOKEN_VERSION: + raise LicenseValidationError("license token payload is invalid") + + public_key = _load_ed25519_public_key(public_key_value) + signing_input = f"{parts[0]}.{parts[1]}".encode("ascii") + signature = _b64url_decode(parts[2]) + try: + public_key.verify(signature, signing_input) + except InvalidSignature as exc: + raise LicenseValidationError("license token signature is invalid") from exc + + payload_bytes = _b64url_decode(parts[1]) + try: + decoded = json.loads(payload_bytes.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise LicenseValidationError("license token payload is invalid") from exc + if not isinstance(decoded, dict): + raise LicenseValidationError("license token payload is invalid") + + _validate_license_payload(decoded, now=now or int(time.time())) + _reject_revoked_license(decoded) + + +def _matches_static_license_key(candidate: str) -> bool: + expected = settings.license_key + return expected is not None and hmac.compare_digest(candidate, expected) + + +def require_active_license( + x_license_key: str | None = Header(default=None, alias=LICENSE_HEADER_NAME), +) -> None: + """Reject requests without a valid license key when required.""" + if not _license_required(): + return + + if not (settings.license_key or settings.license_public_key): + # This indicates a deployment misconfiguration in required mode. + raise HTTPException( + status_code=500, + detail="license key is not configured for required mode", + ) + + candidate = _normalize_key(x_license_key) + if not candidate: + raise HTTPException(status_code=403, detail="invalid or missing license key") + + if candidate.startswith(f"{LICENSE_TOKEN_VERSION}."): + try: + _validate_signed_license_token(candidate, settings.license_public_key) + except LicenseConfigurationError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + except LicenseValidationError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + return + + if _matches_static_license_key(candidate): + return + + raise HTTPException(status_code=403, detail="invalid or missing license key") diff --git a/backend/app/license_tokens.py b/backend/app/license_tokens.py new file mode 100644 index 00000000..b72f1c7c --- /dev/null +++ b/backend/app/license_tokens.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import argparse +import base64 +import binascii +import json +import sys +from dataclasses import dataclass +from datetime import date, datetime, time, timezone +from typing import Sequence + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + PublicFormat, + load_pem_private_key, +) + +LICENSE_TOKEN_VERSION = "v1" + + +@dataclass(frozen=True) +class LicenseKeyPair: + private_key: str + public_key: str + + +def _b64url_encode(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") + + +def _b64url_decode(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + try: + return base64.b64decode( + (value + padding).encode("ascii"), + altchars=b"-_", + validate=True, + ) + except (binascii.Error, UnicodeEncodeError, ValueError) as exc: + raise ValueError("key must be base64url encoded") from exc + + +def generate_license_key_pair() -> LicenseKeyPair: + private_key = Ed25519PrivateKey.generate() + raw_private_key = private_key.private_bytes( + encoding=Encoding.Raw, + format=PrivateFormat.Raw, + encryption_algorithm=NoEncryption(), + ) + raw_public_key = private_key.public_key().public_bytes( + encoding=Encoding.Raw, + format=PublicFormat.Raw, + ) + return LicenseKeyPair( + private_key=_b64url_encode(raw_private_key), + public_key=_b64url_encode(raw_public_key), + ) + + +def _load_private_key(value: str) -> Ed25519PrivateKey: + key_text = value.strip().replace("\\n", "\n") + if not key_text: + raise ValueError("private key is required") + + if "-----BEGIN" in key_text: + loaded_key = load_pem_private_key(key_text.encode("utf-8"), password=None) + if not isinstance(loaded_key, Ed25519PrivateKey): + raise ValueError("private key must be an Ed25519 private key") + return loaded_key + + try: + return Ed25519PrivateKey.from_private_bytes(_b64url_decode(key_text)) + except ValueError as exc: + raise ValueError("private key must be an Ed25519 private key") from exc + + +def _clean_claim(value: str, claim: str) -> str: + if not value.strip() or value != value.strip(): + raise ValueError(f"{claim} must be a non-empty string without edge whitespace") + return value + + +def _validate_positive_epoch(value: int, claim: str) -> int: + if value <= 0: + raise ValueError(f"{claim} must be a positive Unix epoch seconds value") + return value + + +def issue_license_token( + *, + private_key: str, + subject: str, + plan: str, + expires_at: int, + token_id: str | None = None, + not_before: int | None = None, + seats: int | None = None, +) -> str: + payload: dict[str, object] = { + "sub": _clean_claim(subject, "sub"), + "plan": _clean_claim(plan, "plan"), + "exp": _validate_positive_epoch(expires_at, "exp"), + } + if token_id is not None: + payload["jti"] = _clean_claim(token_id, "jti") + if not_before is not None: + payload["nbf"] = _validate_positive_epoch(not_before, "nbf") + if seats is not None: + if seats <= 0: + raise ValueError("seats must be a positive integer") + payload["seats"] = seats + + encoded_payload = _b64url_encode( + json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + ) + signing_input = f"{LICENSE_TOKEN_VERSION}.{encoded_payload}".encode("ascii") + signature = _load_private_key(private_key).sign(signing_input) + return f"{LICENSE_TOKEN_VERSION}.{encoded_payload}.{_b64url_encode(signature)}" + + +def _parse_epoch_or_iso(value: str) -> int: + try: + return int(value) + except ValueError: + pass + + try: + if len(value) == 10: + parsed_date = date.fromisoformat(value) + parsed_datetime = datetime.combine( + parsed_date, + time.min, + tzinfo=timezone.utc, + ) + else: + parsed_datetime = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed_datetime.tzinfo is None: + parsed_datetime = parsed_datetime.replace(tzinfo=timezone.utc) + return int(parsed_datetime.timestamp()) + except ValueError as exc: + raise argparse.ArgumentTypeError( + "expected Unix epoch seconds, YYYY-MM-DD, or ISO-8601 datetime" + ) from exc + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Generate and issue pg-erd-cloud offline license tokens." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + keypair_parser = subparsers.add_parser( + "generate-keypair", + help="Generate an Ed25519 key pair for offline license issuance.", + ) + keypair_parser.add_argument( + "--format", + choices=("env", "json"), + default="env", + help="Output format. The private key must remain outside runtime deployments.", + ) + + issue_parser = subparsers.add_parser( + "issue", + help="Issue a signed offline license token for X-LICENSE-KEY.", + ) + issue_parser.add_argument("--private-key", required=True) + issue_parser.add_argument("--sub", required=True, dest="subject") + issue_parser.add_argument("--plan", required=True) + issue_parser.add_argument("--exp", required=True, type=_parse_epoch_or_iso) + issue_parser.add_argument("--jti", dest="token_id") + issue_parser.add_argument("--nbf", type=_parse_epoch_or_iso, dest="not_before") + issue_parser.add_argument("--seats", type=int) + return parser + + +def _coalesce_private_key_arg(argv: Sequence[str] | None) -> list[str]: + args = list(sys.argv[1:] if argv is None else argv) + normalized: list[str] = [] + index = 0 + while index < len(args): + # Base64url Ed25519 private keys can start with "-", which argparse + # otherwise treats as the next option instead of the option value. + if ( + args[index] == "--private-key" + and index + 1 < len(args) + and args[index + 1].startswith("-") + ): + normalized.append(f"--private-key={args[index + 1]}") + index += 2 + continue + normalized.append(args[index]) + index += 1 + return normalized + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(_coalesce_private_key_arg(argv)) + + if args.command == "generate-keypair": + key_pair = generate_license_key_pair() + if args.format == "json": + print(json.dumps(key_pair.__dict__, sort_keys=True)) + else: + print(f"LICENSE_PRIVATE_KEY={key_pair.private_key}") + print(f"LICENSE_PUBLIC_KEY={key_pair.public_key}") + return 0 + + if args.command == "issue": + print( + issue_license_token( + private_key=args.private_key, + subject=args.subject, + plan=args.plan, + expires_at=args.exp, + token_id=args.token_id, + not_before=args.not_before, + seats=args.seats, + ) + ) + return 0 + + parser.error("unknown command") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/app/llm_quota.py b/backend/app/llm_quota.py new file mode 100644 index 00000000..af0de648 --- /dev/null +++ b/backend/app/llm_quota.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from fastapi import HTTPException + +from app.rate_limit import InMemoryFixedWindowRateLimiter, RateLimitPolicy +from app.settings import settings + + +_LLM_DRAFT_LIMITER = InMemoryFixedWindowRateLimiter(max_keys=10_000) + + +def _llm_draft_policy() -> RateLimitPolicy: + return RateLimitPolicy( + enabled=settings.llm_draft_quota_enabled, + requests=settings.llm_draft_quota_requests, + window_seconds=settings.llm_draft_quota_window_seconds, + ) + + +async def enforce_llm_draft_quota(key: str) -> None: + """Enforce a per-key live LLM draft quota before provider calls.""" + policy = _llm_draft_policy() + if not policy.enabled: + return + + allowed, retry_after = await _LLM_DRAFT_LIMITER.hit( + key=f"llm-draft:{key}", + policy=policy, + ) + if allowed: + return + + raise HTTPException( + status_code=429, + detail="LLM draft quota exceeded", + headers={"Retry-After": str(retry_after)}, + ) + + +def reset_llm_draft_quota_state() -> None: + """Clear in-process quota state for tests and controlled local drills.""" + _LLM_DRAFT_LIMITER._buckets.clear() diff --git a/backend/app/llm_usage.py b/backend/app/llm_usage.py new file mode 100644 index 00000000..f68bdf06 --- /dev/null +++ b/backend/app/llm_usage.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import json +import logging +import uuid +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.metrics import record_llm_draft_metrics +from app.models import LlmDraftUsageEvent, utcnow + +_LOGGER = logging.getLogger("app.llm_usage") + + +def _snapshot_input_chars(snapshot_json: dict[str, Any]) -> int: + try: + return len( + json.dumps( + snapshot_json, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + ) + except (TypeError, ValueError): + return len(str(snapshot_json)) + + +def record_llm_draft_usage( + *, + surface: str, + artifact: str, + outcome: str, + snapshot_json: dict[str, Any], + output_text: str | None = None, + user_account_uuid: uuid.UUID | None = None, + project_space_uuid: uuid.UUID | None = None, + schema_snapshot_uuid: uuid.UUID | None = None, + share_link_uuid: uuid.UUID | None = None, + error_code: str | None = None, +) -> dict[str, object | None]: + """Record LLM draft cost/usage evidence without logging prompt contents.""" + input_chars = _snapshot_input_chars(snapshot_json) + output_chars = len(output_text) if output_text is not None else None + record_llm_draft_metrics( + surface=surface, + artifact=artifact, + outcome=outcome, + input_chars=input_chars, + output_chars=output_chars, + ) + payload: dict[str, object | None] = { + "event": "llm_draft_usage", + "surface": surface, + "artifact": artifact, + "outcome": outcome, + "input_chars": input_chars, + "output_chars": output_chars, + "user_account_uuid": str(user_account_uuid) if user_account_uuid else None, + "project_space_uuid": str(project_space_uuid) if project_space_uuid else None, + "schema_snapshot_uuid": ( + str(schema_snapshot_uuid) if schema_snapshot_uuid else None + ), + "share_link_uuid": str(share_link_uuid) if share_link_uuid else None, + "error_code": error_code, + } + _LOGGER.info(json.dumps(payload, ensure_ascii=False, separators=(",", ":"))) + return payload + + +async def record_persistent_llm_draft_usage( + session: AsyncSession, + *, + surface: str, + artifact: str, + outcome: str, + snapshot_json: dict[str, Any], + output_text: str | None = None, + subject: str | None = None, + user_account_uuid: uuid.UUID | None = None, + project_space_uuid: uuid.UUID | None = None, + schema_snapshot_uuid: uuid.UUID | None = None, + share_link_uuid: uuid.UUID | None = None, + error_code: str | None = None, +) -> LlmDraftUsageEvent: + """Record metrics/logs and persist monthly billing attribution evidence.""" + input_chars = _snapshot_input_chars(snapshot_json) + output_chars = len(output_text) if output_text is not None else None + record_llm_draft_usage( + surface=surface, + artifact=artifact, + outcome=outcome, + snapshot_json=snapshot_json, + output_text=output_text, + user_account_uuid=user_account_uuid, + project_space_uuid=project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + share_link_uuid=share_link_uuid, + error_code=error_code, + ) + event = LlmDraftUsageEvent( + llm_draft_usage_event_uuid=uuid.uuid4(), + surface=surface, + artifact=artifact, + outcome=outcome, + subject=subject, + user_account_uuid=user_account_uuid, + project_space_uuid=project_space_uuid, + schema_snapshot_uuid=schema_snapshot_uuid, + share_link_uuid=share_link_uuid, + input_chars=input_chars, + output_chars=output_chars, + error_code=error_code, + occurred_at=utcnow(), + ) + session.add(event) + await session.commit() + return event diff --git a/backend/app/main.py b/backend/app/main.py index 41f802c7..dfa3a2f3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,11 +5,12 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.connections import router as connections_router from app.api.auth_routes import router as auth_router +from app.api.billing import router as billing_router from app.api.me import router as me_router from app.api.projects import router as projects_router from app.api.share import router as share_router @@ -25,8 +26,9 @@ RateLimitPolicy, make_rate_limit_middleware, ) +from app.license_gate import require_active_license from app.security_headers import make_security_headers_middleware -from app.settings import settings +from app.settings import settings, validate_production_settings @asynccontextmanager @@ -37,6 +39,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: awaited on shutdown. """ + production_errors = validate_production_settings(settings) + if production_errors: + raise RuntimeError( + "production configuration is invalid: " + "; ".join(production_errors) + ) + handlers = {"snapshot": handle_snapshot_job} task = asyncio.create_task(run_worker_forever(SessionLocal, handlers)) try: @@ -162,9 +170,10 @@ async def csrf_token() -> dict[str, str]: return {"csrf_token": generate_csrf_token(settings.app_secret)} -app.include_router(projects_router) -app.include_router(connections_router) -app.include_router(snapshots_router) -app.include_router(me_router) +app.include_router(projects_router, dependencies=[Depends(require_active_license)]) +app.include_router(connections_router, dependencies=[Depends(require_active_license)]) +app.include_router(snapshots_router, dependencies=[Depends(require_active_license)]) +app.include_router(me_router, dependencies=[Depends(require_active_license)]) +app.include_router(auth_router, dependencies=[Depends(require_active_license)]) +app.include_router(billing_router, dependencies=[Depends(require_active_license)]) app.include_router(share_router) -app.include_router(auth_router) diff --git a/backend/app/metrics.py b/backend/app/metrics.py index 282ab011..5d0097d3 100644 --- a/backend/app/metrics.py +++ b/backend/app/metrics.py @@ -58,6 +58,85 @@ def prime_http_metrics(*, route_methods: dict[str, set[str]]) -> None: ) +AUTHZ_FAILURES_TOTAL = Counter( + "authz_failures_total", + "Total authorization/authentication failures by status, route, and reason.", + ["status", "route", "reason"], +) + + +SHARE_AUDIT_EVENTS_TOTAL = Counter( + "share_audit_events_total", + "Total share audit events emitted by action and outcome.", + ["action", "outcome"], +) + + +BILLING_EVENTS_TOTAL = Counter( + "billing_events_total", + "Total billing reconciliation webhook events by provider, type, and outcome.", + ["provider", "event_type", "outcome"], +) + + +LLM_DRAFT_REQUESTS_TOTAL = Counter( + "llm_draft_requests_total", + "Total live LLM draft requests by surface, artifact, and outcome.", + ["surface", "artifact", "outcome"], +) + + +LLM_DRAFT_INPUT_CHARS = Histogram( + "llm_draft_input_chars", + "Approximate input snapshot JSON size for live LLM drafts.", + ["surface", "artifact"], +) + + +LLM_DRAFT_OUTPUT_CHARS = Histogram( + "llm_draft_output_chars", + "Output draft size for live LLM drafts by surface, artifact, and outcome.", + ["surface", "artifact", "outcome"], +) + + +def record_llm_draft_metrics( + *, + surface: str, + artifact: str, + outcome: str, + input_chars: int, + output_chars: int | None = None, +) -> None: + """Record low-cardinality LLM draft usage metrics.""" + LLM_DRAFT_REQUESTS_TOTAL.labels( + surface=surface, + artifact=artifact, + outcome=outcome, + ).inc() + LLM_DRAFT_INPUT_CHARS.labels(surface=surface, artifact=artifact).observe( + max(input_chars, 0) + ) + if output_chars is not None: + LLM_DRAFT_OUTPUT_CHARS.labels( + surface=surface, + artifact=artifact, + outcome=outcome, + ).observe(max(output_chars, 0)) + + +PRODUCT_EVENTS_TOTAL = Counter( + "product_events_total", + "Total product lifecycle events by area, action, and outcome.", + ["area", "action", "outcome"], +) + + +def record_product_event(area: str, action: str, outcome: str) -> None: + """Record a low-cardinality product lifecycle event.""" + PRODUCT_EVENTS_TOTAL.labels(area=area, action=action, outcome=outcome).inc() + + JOB_QUEUE_JOBS_TOTAL = Counter( "job_queue_jobs_total", "Total number of job queue executions by type/outcome.", diff --git a/backend/app/models.py b/backend/app/models.py index 2a772ac3..f233d9da 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -3,7 +3,15 @@ import datetime as dt import uuid -from sqlalchemy import DateTime, ForeignKey, Index, Integer, LargeBinary, Text +from sqlalchemy import ( + DateTime, + ForeignKey, + Index, + Integer, + LargeBinary, + Text, + UniqueConstraint, +) from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column @@ -209,3 +217,86 @@ class ShareLink(Base): created_at: Mapped[dt.datetime] = mapped_column( DateTime(timezone=True), default=utcnow ) + + +class BillingEvent(Base): + """Provider-neutral billing/license event recorded for reconciliation.""" + + __tablename__ = "billing_event" + + billing_event_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + provider: Mapped[str] = mapped_column(Text()) + provider_event_id: Mapped[str] = mapped_column(Text()) + event_type: Mapped[str] = mapped_column(Text()) + subject: Mapped[str] = mapped_column(Text()) + target_plan: Mapped[str | None] = mapped_column(Text(), nullable=True) + event_status: Mapped[str] = mapped_column(Text()) + occurred_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True)) + received_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), default=utcnow + ) + metadata_json: Mapped[dict] = mapped_column(JSONB()) + + __table_args__ = ( + UniqueConstraint( + "provider", + "provider_event_id", + name="uq_billing_event__provider_event_id", + ), + Index("ix_billing_event__subject_received_at", "subject", "received_at"), + ) + + +class LlmDraftUsageEvent(Base): + """Persistent LLM draft usage evidence for billing attribution.""" + + __tablename__ = "llm_draft_usage_event" + + llm_draft_usage_event_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + surface: Mapped[str] = mapped_column(Text()) + artifact: Mapped[str] = mapped_column(Text()) + outcome: Mapped[str] = mapped_column(Text()) + subject: Mapped[str | None] = mapped_column(Text(), nullable=True) + user_account_uuid: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("user_account.user_account_uuid", ondelete="SET NULL"), + nullable=True, + ) + project_space_uuid: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("project_space.project_space_uuid", ondelete="SET NULL"), + nullable=True, + ) + schema_snapshot_uuid: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("schema_snapshot.schema_snapshot_uuid", ondelete="SET NULL"), + nullable=True, + ) + share_link_uuid: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("share_link.share_link_uuid", ondelete="SET NULL"), + nullable=True, + ) + input_chars: Mapped[int] = mapped_column(Integer()) + output_chars: Mapped[int | None] = mapped_column(Integer(), nullable=True) + error_code: Mapped[str | None] = mapped_column(Text(), nullable=True) + occurred_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), default=utcnow + ) + + __table_args__ = ( + Index( + "ix_llm_draft_usage_event__account_month", + "user_account_uuid", + "occurred_at", + ), + Index( + "ix_llm_draft_usage_event__project_month", + "project_space_uuid", + "occurred_at", + ), + ) diff --git a/backend/app/observability.py b/backend/app/observability.py index fcc189b3..51b8729f 100644 --- a/backend/app/observability.py +++ b/backend/app/observability.py @@ -16,10 +16,12 @@ import uuid from collections.abc import Awaitable, Callable -from fastapi import FastAPI, Response -from starlette.requests import Request +from fastapi import FastAPI, HTTPException, Request, Response +from fastapi.encoders import jsonable_encoder +from starlette.responses import JSONResponse from app.metrics import ( + AUTHZ_FAILURES_TOTAL, HTTP_REQUEST_DURATION_SECONDS, HTTP_REQUESTS_TOTAL, normalize_route_label, @@ -31,6 +33,35 @@ _logger = logging.getLogger("app.observability") _SAFE_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") +_AUTHZ_HTTP_STATUS = {401, 403} +_UNKNOWN_AUTHZ_REASON = "unknown" +_AUTHZ_DETAIL_REASON_MAP = { + "missing bearer token": "missing_bearer_token", + "invalid token header": "invalid_token_header", + "unsupported token type": "unsupported_token_type", + "unsupported token content type": "unsupported_token_content_type", + "token missing alg": "missing_token_alg", + "token missing exp": "token_missing_exp", + "token missing sub": "token_missing_sub", + "token missing jti": "token_missing_jti", + "token revoked": "token_revoked", + "unsupported token algorithm": "unsupported_token_algorithm", + "unknown signing key": "unknown_signing_key", + "algorithm/key type mismatch": "algorithm_key_type_mismatch", + "token verification failed": "token_verification_failed", + "account deactivated": "account_deactivated", + "owner role required": "owner_role_required", + "share link LLM draft is disabled": "share_llm_draft_disabled", + "license key is not configured for required mode": "license_key_not_configured", + "license verification key is not configured for required mode": "license_key_not_configured", + "license verification key must be an Ed25519 public key": "license_public_key_invalid", + "invalid or missing license key": "license_key_invalid", + "license token payload is invalid": "license_token_invalid", + "license token signature is invalid": "license_token_signature_invalid", + "license token is expired": "license_token_expired", + "license token is not yet valid": "license_token_not_yet_valid", + "license token is revoked": "license_token_revoked", +} def _utc_now_iso() -> str: @@ -110,6 +141,88 @@ def _record_metrics_and_logs( ) +def _record_authz_event(request: Request, request_id: str, status: int) -> None: + if status not in _AUTHZ_HTTP_STATUS: + return + + reason = _classify_authz_reason(None) + if settings.observability_metrics_enabled: + AUTHZ_FAILURES_TOTAL.labels( + status=str(status), + route=normalize_route_label(_get_route_template(request)), + reason=reason, + ).inc() + + if status in _AUTHZ_HTTP_STATUS: + _log_json( + "authz_failure", + { + "request_id": request_id, + "method": request.method, + "route": _get_route_template(request), + "status": status, + "reason": reason, + "client_ip": _get_client_ip(request), + }, + level=logging.WARNING, + ) + + +def _classify_authz_reason(detail: object) -> str: + if not isinstance(detail, str): + return _UNKNOWN_AUTHZ_REASON + normalized = detail.strip().lower() + if not normalized: + return _UNKNOWN_AUTHZ_REASON + return _AUTHZ_DETAIL_REASON_MAP.get(normalized, _UNKNOWN_AUTHZ_REASON) + + +def _make_http_exception_handler() -> Callable[[Request, Exception], Awaitable[Response]]: + async def handler(request: Request, exc: Exception) -> Response: + if not isinstance(exc, HTTPException): + raise exc + + request_id = getattr(request.state, "request_id", None) + if request_id is None: + request_id = str(uuid.uuid4()) + request.state.request_id = request_id + + status = int(exc.status_code) + response_headers: dict[str, str] = dict(exc.headers or {}) + if status in _AUTHZ_HTTP_STATUS and settings.observability_request_logging_enabled: + reason = _classify_authz_reason(exc.detail) + if settings.observability_metrics_enabled: + AUTHZ_FAILURES_TOTAL.labels( + status=str(status), + route=normalize_route_label(_get_route_template(request)), + reason=reason, + ).inc() + response_headers["X-Error-Code"] = reason + _log_json( + "authz_failure", + { + "request_id": request_id, + "method": request.method, + "route": _get_route_template(request), + "status": status, + "reason": reason, + "detail": jsonable_encoder(exc.detail), + "client_ip": _get_client_ip(request), + }, + level=logging.WARNING, + ) + setattr(request.state, "authz_event_emitted", True) + + response_headers.setdefault("X-Request-Id", request_id) + return JSONResponse( + status_code=status, + content={"detail": exc.detail}, + headers=response_headers, + ) + + return handler + + def make_request_observability_middleware() -> Callable[ [Request, Callable[[Request], Awaitable[Response]]], Awaitable[Response] ]: @@ -128,6 +241,7 @@ async def middleware( request_id = raw_request_id else: request_id = str(uuid.uuid4()) + request.state.request_id = request_id start = time.perf_counter() try: @@ -152,6 +266,11 @@ async def middleware( _record_metrics_and_logs( request, request_id, status, duration_s, is_metrics_path ) + if ( + status in _AUTHZ_HTTP_STATUS + and not getattr(request.state, "authz_event_emitted", False) + ): + _record_authz_event(request, request_id, status) return response return middleware @@ -160,6 +279,7 @@ async def middleware( def setup_observability(app: FastAPI) -> None: """Register observability hooks on the given FastAPI app.""" app.middleware("http")(make_request_observability_middleware()) + app.add_exception_handler(HTTPException, _make_http_exception_handler()) if not settings.observability_metrics_enabled: return diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 0359799c..41360957 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,7 +1,8 @@ from __future__ import annotations +import datetime as dt import uuid -from typing import Literal +from typing import Any, Literal from pydantic import BaseModel, Field @@ -91,9 +92,239 @@ class SnapshotDetailOut(BaseModel): snapshot_json: dict | None +class ShareLinkCreateIn(BaseModel): + """Request body for creating a public project share link.""" + + expires_in_hours: int | None = Field( + default=None, + ge=0, + le=8760, + description=( + "Override the default share-link TTL. Use 0 only for an explicit " + "non-expiring operational exception." + ), + ) + + +class ShareLinkOut(BaseModel): + """Share link representation returned by API.""" + + share_link_uuid: uuid.UUID + permission_kind: str + url_path: str + expires_at: dt.datetime | None + created_at: dt.datetime + + class MeOut(BaseModel): """Current user payload returned by /me.""" user_account_uuid: uuid.UUID subject: str display_name: str | None + support_operator: bool = False + + +class BillingUsageOut(BaseModel): + """Read-only usage counters for billing and license operations.""" + + scope: Literal["owned_projects"] + account_status: Literal["active"] + license_mode: Literal["off", "required"] + license_verifier: Literal[ + "none", "static_key", "signed_token", "static_key_and_signed_token" + ] + billing_portal_url: str | None + billing_support_url: str | None + account_reactivation_url: str | None + project_count: int = Field(ge=0) + seat_count: int = Field(ge=0) + connection_count: int = Field(ge=0) + snapshot_count: int = Field(ge=0) + share_link_count: int = Field(ge=0) + active_share_link_count: int = Field(ge=0) + project_limit: int = Field(ge=0) + connection_limit: int = Field(ge=0) + snapshot_limit: int = Field(ge=0) + share_link_limit: int = Field(ge=0) + + +class BillingLlmUsageOut(BaseModel): + """Monthly account-level LLM draft usage for billing attribution.""" + + scope: Literal["account"] + month: str = Field(pattern=r"^\d{4}-\d{2}$") + request_count: int = Field(ge=0) + success_count: int = Field(ge=0) + failure_count: int = Field(ge=0) + quota_exceeded_count: int = Field(ge=0) + input_chars: int = Field(ge=0) + output_chars: int = Field(ge=0) + + +class BillingPlanChangeIn(BaseModel): + """Request body for starting a billing plan-change flow.""" + + target_plan: str = Field( + min_length=1, + max_length=64, + pattern=r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$", + ) + + +class BillingPlanChangeOut(BaseModel): + """Billing plan-change action returned to the client.""" + + action: Literal["portal_redirect", "contact_support"] + target_plan: str + billing_portal_url: str | None + billing_support_url: str | None + message: str + + +class BillingCheckoutOut(BaseModel): + """Billing checkout action returned to the client.""" + + action: Literal["checkout_redirect", "contact_support"] + target_plan: str + billing_checkout_url: str | None + billing_support_url: str | None + message: str + + +class BillingEventIn(BaseModel): + """Provider-neutral billing/license event for reconciliation.""" + + provider: str = Field( + min_length=1, + max_length=64, + pattern=r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$", + ) + provider_event_id: str = Field( + min_length=1, + max_length=128, + pattern=r"^[^\s\x00-\x1F\x7F]+$", + ) + event_type: str = Field( + min_length=1, + max_length=128, + pattern=r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$", + ) + subject: str = Field( + min_length=1, + max_length=128, + pattern=r"^[^\x00-\x1F\x7F]+$", + ) + target_plan: str | None = Field( + default=None, + min_length=1, + max_length=64, + pattern=r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$", + ) + occurred_at: dt.datetime | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class BillingEventOut(BaseModel): + """Billing reconciliation event status returned to the caller.""" + + action: Literal["recorded", "duplicate"] + billing_event_uuid: uuid.UUID + provider: str + provider_event_id: str + event_type: str + subject: str + target_plan: str | None + status: Literal["recorded"] + occurred_at: dt.datetime + received_at: dt.datetime + message: str + + +class BillingEventMetadataSummaryOut(BaseModel): + """Redacted billing metadata field safe for support diagnostics.""" + + key: str = Field(min_length=1, max_length=96) + value: str = Field(min_length=1, max_length=240) + + +class BillingEventSummaryOut(BaseModel): + """Billing event summary safe for support diagnostics.""" + + billing_event_uuid: uuid.UUID + provider: str + provider_event_id: str + event_type: str + target_plan: str | None + status: Literal["recorded"] + occurred_at: dt.datetime + received_at: dt.datetime + metadata_summary: list[BillingEventMetadataSummaryOut] = Field( + default_factory=list + ) + + +class BillingSupportShareLinkSummaryOut(BaseModel): + """Share-link summary safe for support diagnostics.""" + + share_link_uuid: uuid.UUID + project_space_uuid: uuid.UUID + permission_kind: str + status: Literal["active", "expired"] + expires_at: dt.datetime | None + created_at: dt.datetime + + +class BillingEntitlementOut(BaseModel): + """Latest provider-neutral entitlement evidence for a billing subject.""" + + plan: str | None + seat_count: int | None = Field(default=None, ge=1) + source_provider: str | None + source_provider_event_id: str | None + source_event_type: str | None + source_occurred_at: dt.datetime | None + + +class BillingSeatReconciliationCandidateOut(BaseModel): + """Support-visible member candidate for manual seat deprovisioning review.""" + + member_subject: str + project_count: int = Field(ge=0) + + +class BillingSeatReconciliationOut(BaseModel): + """Read-only comparison between contracted seats and active app seats.""" + + status: Literal["unknown_account", "not_configured", "within_limit", "over_limit"] + contracted_seat_count: int | None = Field(default=None, ge=1) + active_seat_count: int = Field(ge=0) + seats_over_limit: int = Field(ge=0) + deprovisioning_required: bool + deprovisioning_candidates: list[BillingSeatReconciliationCandidateOut] + + +class BillingSupportAccountOut(BaseModel): + """Read-only account diagnostics for authorized support operators.""" + + subject: str + user_account_uuid: uuid.UUID | None + account_status: Literal["active", "deactivated", "unknown"] + license_mode: Literal["off", "required"] + license_verifier: Literal[ + "none", "static_key", "signed_token", "static_key_and_signed_token" + ] + billing_portal_url: str | None + billing_support_url: str | None + account_reactivation_url: str | None + project_count: int = Field(ge=0) + seat_count: int = Field(ge=0) + connection_count: int = Field(ge=0) + snapshot_count: int = Field(ge=0) + share_link_count: int = Field(ge=0) + active_share_link_count: int = Field(ge=0) + billing_entitlement: BillingEntitlementOut + billing_seat_reconciliation: BillingSeatReconciliationOut + llm_usage_current_month: BillingLlmUsageOut + recent_share_links: list[BillingSupportShareLinkSummaryOut] + recent_billing_events: list[BillingEventSummaryOut] diff --git a/backend/app/settings.py b/backend/app/settings.py index 0d1a7b91..a930b0bc 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -1,7 +1,9 @@ from __future__ import annotations +import re from pathlib import Path from typing import Literal +from urllib.parse import urlparse from pydantic import Field from pydantic import model_validator @@ -14,6 +16,8 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore") database_url: str + app_env: Literal["development", "production"] = "development" + production_config_checks_enabled: bool = True # Optional: a read-only endpoint/replica DSN. database_read_only_url: str | None = None @@ -90,6 +94,7 @@ def _load_app_secret_from_file(cls, data: object) -> object: share_link_rate_limit_requests: int = Field(30, ge=1) share_link_rate_limit_window_seconds: float = Field(60.0, gt=0.0) share_link_rate_limit_max_keys: int = Field(10_000, ge=1) + share_link_default_ttl_hours: int = Field(168, ge=0) # Observability (MVP) observability_request_logging_enabled: bool = True @@ -116,12 +121,56 @@ def _load_app_secret_from_file(cls, data: object) -> object: # Comma-separated exact hostnames/IPs or wildcard domains like *.example.com. db_introspection_allowed_hosts: str = "" + # Optional commercial/on-prem licensing gate. + # + # Set LICENSE_MODE=required to reject API usage without a valid + # X-LICENSE-KEY header when running paid/on-prem distribution. + license_mode: Literal["off", "required"] = "off" + license_key: str | None = None + # Optional Ed25519 public key for offline signed on-prem license tokens. + license_public_key: str | None = None + # Comma-separated signed license token IDs (jti) or customer subjects (sub) + # that must be rejected before their natural expiry. + license_revoked_token_ids: str = "" + license_revoked_subjects: str = "" + + # Optional billing/license usage limits. A value of 0 means unlimited. + billing_max_projects_per_user: int = Field(0, ge=0) + billing_max_connections_per_project: int = Field(0, ge=0) + billing_max_snapshots_per_project: int = Field(0, ge=0) + billing_max_share_links_per_project: int = Field(0, ge=0) + billing_checkout_url: str | None = None + billing_portal_url: str | None = None + billing_support_url: str | None = None + billing_webhook_secret: str | None = None + billing_webhook_signature_secret: str | None = None + billing_allowed_plans: str = "" + billing_event_type_aliases: str = "" + billing_entitlement_event_types: str = ( + "checkout.completed,invoice.paid,subscription.created," + "subscription.updated,contract.activated,contract.reactivated" + ) + billing_contract_state_events_enabled: bool = False + billing_contract_deactivated_event_types: str = "contract.deactivated,contract.suspended" + billing_contract_active_event_types: str = "contract.activated,contract.reactivated" + account_reactivation_url: str | None = None + # Comma-separated OIDC subjects that must be denied before DB access. + account_deactivated_subjects: str = "" + # Comma-separated OIDC subjects allowed to view support diagnostics. + support_operator_subjects: str = "" + # Optional OpenAI-compatible chat-completions provider for live reversing # spec drafts. Leave unset to keep all reversing spec generation local. llm_api_base_url: str | None = None llm_api_key: str | None = None llm_model: str | None = None llm_timeout_seconds: float = Field(30.0, gt=0.0, le=120.0) + llm_max_prompt_chars: int = Field(120_000, ge=1_000) + llm_max_output_tokens: int = Field(1_200, ge=1, le=8_192) + llm_draft_quota_enabled: bool = True + llm_draft_quota_requests: int = Field(20, ge=1) + llm_draft_quota_window_seconds: float = Field(3600.0, gt=0.0) + share_link_llm_draft_enabled: bool = False # Allowed JWT signing algorithms for OIDC verification. # Comma-separated string (env: OIDC_ALGORITHMS). Default is RS256. @@ -130,4 +179,163 @@ def _load_app_secret_from_file(cls, data: object) -> object: oidc_algorithms: str = "RS256" +def _split_csv(value: str) -> list[str]: + return [item.strip() for item in value.split(",") if item.strip()] + + +def _invalid_key_value_csv(value: str) -> list[str]: + invalid: list[str] = [] + for item in _split_csv(value): + key, separator, mapped = item.partition("=") + if separator != "=" or not key.strip() or not mapped.strip(): + invalid.append(item) + return invalid + + +_BILLING_PLAN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$") +_BILLING_EVENT_TYPE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$") + + +def _invalid_billing_plan_ids(value: str) -> list[str]: + return [item for item in _split_csv(value) if not _BILLING_PLAN_ID_RE.fullmatch(item)] + + +def _invalid_billing_event_type_ids(value: str) -> list[str]: + return [ + item for item in _split_csv(value) if not _BILLING_EVENT_TYPE_RE.fullmatch(item) + ] + + +def _is_public_https_origin(origin: str) -> bool: + parsed = urlparse(origin) + host = parsed.hostname or "" + return ( + parsed.scheme == "https" + and bool(parsed.netloc) + and host not in {"localhost", "127.0.0.1", "::1", "0.0.0.0"} + ) + + +def _append_public_https_url_error( + errors: list[str], + *, + setting_name: str, + setting_value: str | None, +) -> None: + if setting_value and not _is_public_https_origin(setting_value): + errors.append(f"{setting_name} must be a public HTTPS URL in production") + + +def validate_production_settings(config: Settings) -> list[str]: + """Return startup-blocking configuration errors for production deployments.""" + if config.app_env != "production" or not config.production_config_checks_enabled: + return [] + + errors: list[str] = [] + if not config.oidc_issuer: + errors.append("OIDC_ISSUER is required when APP_ENV=production") + if not config.oidc_audience: + errors.append("OIDC_AUDIENCE is required when APP_ENV=production") + if len(config.app_secret) < 32: + errors.append("APP_SECRET must be at least 32 characters in production") + if not _split_csv(config.db_introspection_allowed_hosts): + errors.append( + "DB_INTROSPECTION_ALLOWED_HOSTS must allow explicit target DB hosts" + ) + if not any(_is_public_https_origin(origin) for origin in _split_csv(config.cors_origins)): + errors.append("CORS_ORIGINS must include at least one public HTTPS origin") + if config.share_link_default_ttl_hours == 0: + errors.append("SHARE_LINK_DEFAULT_TTL_HOURS must be greater than 0") + if config.share_link_llm_draft_enabled and not ( + config.llm_api_base_url and config.llm_api_key and config.llm_model + ): + errors.append( + "LLM_API_BASE_URL, LLM_API_KEY, and LLM_MODEL are required when " + "SHARE_LINK_LLM_DRAFT_ENABLED=true" + ) + if ( + config.llm_api_base_url + and config.llm_api_key + and config.llm_model + and not config.llm_draft_quota_enabled + ): + errors.append( + "LLM_DRAFT_QUOTA_ENABLED must stay true when live LLM provider is configured" + ) + if config.license_mode == "required": + if not (config.license_key or config.license_public_key): + errors.append( + "LICENSE_KEY or LICENSE_PUBLIC_KEY is required when LICENSE_MODE=required" + ) + if config.license_key and len(config.license_key) < 24: + errors.append("LICENSE_KEY must be at least 24 characters") + _append_public_https_url_error( + errors, + setting_name="BILLING_CHECKOUT_URL", + setting_value=config.billing_checkout_url, + ) + _append_public_https_url_error( + errors, + setting_name="BILLING_PORTAL_URL", + setting_value=config.billing_portal_url, + ) + _append_public_https_url_error( + errors, + setting_name="BILLING_SUPPORT_URL", + setting_value=config.billing_support_url, + ) + _append_public_https_url_error( + errors, + setting_name="ACCOUNT_REACTIVATION_URL", + setting_value=config.account_reactivation_url, + ) + if config.billing_webhook_secret and len(config.billing_webhook_secret) < 24: + errors.append("BILLING_WEBHOOK_SECRET must be at least 24 characters") + if config.billing_webhook_signature_secret and ( + len(config.billing_webhook_signature_secret) < 32 + ): + errors.append( + "BILLING_WEBHOOK_SIGNATURE_SECRET must be at least 32 characters" + ) + if _split_csv(config.account_deactivated_subjects) and not ( + config.account_reactivation_url or config.billing_support_url + ): + errors.append( + "ACCOUNT_DEACTIVATED_SUBJECTS requires ACCOUNT_REACTIVATION_URL or " + "BILLING_SUPPORT_URL in production" + ) + if config.billing_contract_state_events_enabled: + if not _split_csv(config.billing_contract_deactivated_event_types): + errors.append( + "BILLING_CONTRACT_DEACTIVATED_EVENT_TYPES is required when " + "BILLING_CONTRACT_STATE_EVENTS_ENABLED=true" + ) + if not (config.account_reactivation_url or config.billing_support_url): + errors.append( + "BILLING_CONTRACT_STATE_EVENTS_ENABLED requires " + "ACCOUNT_REACTIVATION_URL or BILLING_SUPPORT_URL in production" + ) + if not ( + config.billing_webhook_secret or config.billing_webhook_signature_secret + ): + errors.append( + "BILLING_CONTRACT_STATE_EVENTS_ENABLED requires " + "BILLING_WEBHOOK_SECRET or BILLING_WEBHOOK_SIGNATURE_SECRET " + "in production" + ) + if _invalid_key_value_csv(config.billing_event_type_aliases): + errors.append( + "BILLING_EVENT_TYPE_ALIASES entries must use source=target format" + ) + if _invalid_billing_plan_ids(config.billing_allowed_plans): + errors.append( + "BILLING_ALLOWED_PLANS entries must be provider catalog plan IDs" + ) + if _invalid_billing_event_type_ids(config.billing_entitlement_event_types): + errors.append( + "BILLING_ENTITLEMENT_EVENT_TYPES entries must be billing event type IDs" + ) + return errors + + settings = Settings() # type: ignore[call-arg] diff --git a/backend/app/spec/llm.py b/backend/app/spec/llm.py index 1d899de6..995af9c5 100644 --- a/backend/app/spec/llm.py +++ b/backend/app/spec/llm.py @@ -15,6 +15,10 @@ class LlmProviderError(RuntimeError): """Raised when the configured LLM provider does not return a usable draft.""" +class LlmPromptTooLargeError(RuntimeError): + """Raised when a generated LLM prompt exceeds the configured size guard.""" + + def _required_setting(value: str | None, name: str) -> str: if value is None or not value.strip(): raise LlmConfigurationError(f"{name} is required for live LLM drafts") @@ -82,6 +86,11 @@ async def _generate_llm_draft( base_url = _required_setting(settings.llm_api_base_url, "LLM_API_BASE_URL") api_key = _required_setting(settings.llm_api_key, "LLM_API_KEY") model = _required_setting(settings.llm_model, "LLM_MODEL") + if len(prompt) > settings.llm_max_prompt_chars: + raise LlmPromptTooLargeError( + "LLM prompt exceeds LLM_MAX_PROMPT_CHARS; use llm-prompt export " + "or reduce snapshot scope" + ) request_json = { "model": model, "messages": [ @@ -89,6 +98,7 @@ async def _generate_llm_draft( {"role": "user", "content": prompt}, ], "temperature": 0.2, + "max_tokens": settings.llm_max_output_tokens, } headers = { "Authorization": f"Bearer {api_key}", diff --git a/backend/app/usage_quotas.py b/backend/app/usage_quotas.py new file mode 100644 index 00000000..4115db89 --- /dev/null +++ b/backend/app/usage_quotas.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import uuid +from typing import Any + +from fastapi import HTTPException +from sqlalchemy import distinct, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.billing_entitlements import latest_billing_entitlement_for_subject +from app.models import ( + DbConnection, + ProjectMember, + ProjectSpace, + SchemaSnapshot, + ShareLink, +) +from app.settings import settings + + +async def _scalar_count(session: AsyncSession, stmt: Any) -> int: + result = await session.execute(stmt) + value = result.scalar_one() + return int(value or 0) + + +def _raise_quota_exceeded(resource_name: str) -> None: + raise HTTPException(status_code=403, detail=f"{resource_name} quota exceeded") + + +async def enforce_project_quota( + session: AsyncSession, + user_account_uuid: uuid.UUID, +) -> None: + limit = settings.billing_max_projects_per_user + if limit <= 0: + return + + current_count = await _scalar_count( + session, + select(func.count()).select_from(ProjectSpace).where( + ProjectSpace.created_by_user_uuid == user_account_uuid + ), + ) + if current_count >= limit: + _raise_quota_exceeded("project") + + +async def enforce_connection_quota( + session: AsyncSession, + project_space_uuid: uuid.UUID, +) -> None: + limit = settings.billing_max_connections_per_project + if limit <= 0: + return + + current_count = await _scalar_count( + session, + select(func.count()).select_from(DbConnection).where( + DbConnection.project_space_uuid == project_space_uuid + ), + ) + if current_count >= limit: + _raise_quota_exceeded("connection") + + +async def enforce_snapshot_quota( + session: AsyncSession, + project_space_uuid: uuid.UUID, +) -> None: + limit = settings.billing_max_snapshots_per_project + if limit <= 0: + return + + current_count = await _scalar_count( + session, + select(func.count()).select_from(SchemaSnapshot).where( + SchemaSnapshot.project_space_uuid == project_space_uuid + ), + ) + if current_count >= limit: + _raise_quota_exceeded("snapshot") + + +async def enforce_share_link_quota( + session: AsyncSession, + project_space_uuid: uuid.UUID, +) -> None: + limit = settings.billing_max_share_links_per_project + if limit <= 0: + return + + current_count = await _scalar_count( + session, + select(func.count()).select_from(ShareLink).where( + ShareLink.project_space_uuid == project_space_uuid + ), + ) + if current_count >= limit: + _raise_quota_exceeded("share link") + + +async def enforce_seat_quota( + session: AsyncSession, + *, + owner_user_account_uuid: uuid.UUID, + owner_subject: str, + candidate_user_account_uuid: uuid.UUID | None, +) -> None: + entitlement = await latest_billing_entitlement_for_subject(session, owner_subject) + limit = entitlement.seat_count + if limit is None: + return + + owned_project_ids = select(ProjectSpace.project_space_uuid).where( + ProjectSpace.created_by_user_uuid == owner_user_account_uuid + ) + current_count = await _scalar_count( + session, + select(func.count(distinct(ProjectMember.user_account_uuid))).where( + ProjectMember.project_space_uuid.in_(owned_project_ids) + ), + ) + if current_count < limit: + return + + if candidate_user_account_uuid is not None: + existing_count = await _scalar_count( + session, + select(func.count()).select_from(ProjectMember).where( + ProjectMember.project_space_uuid.in_(owned_project_ids), + ProjectMember.user_account_uuid == candidate_user_account_uuid, + ), + ) + if existing_count > 0: + return + + _raise_quota_exceeded("seat") diff --git a/backend/tests/test_api_me.py b/backend/tests/test_api_me.py new file mode 100644 index 00000000..05bd817f --- /dev/null +++ b/backend/tests/test_api_me.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import uuid +from collections.abc import Iterator + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.me import router +from app.auth import CurrentUser, get_current_user +from app.settings import settings + +app = FastAPI() +app.include_router(router) + + +@pytest.fixture(autouse=True) +def clear_dependency_overrides() -> Iterator[None]: + yield + app.dependency_overrides.clear() + + +def _current_user(subject: str = "customer-owner") -> CurrentUser: + return CurrentUser( + user_account_uuid=uuid.uuid4(), + subject=subject, + display_name="Customer Owner", + ) + + +def test_me_marks_support_operator_from_allowlist( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "support_operator_subjects", "support-operator") + app.dependency_overrides[get_current_user] = lambda: _current_user( + "support-operator" + ) + + response = TestClient(app).get("/api/me") + + assert response.status_code == 200 + assert response.json()["subject"] == "support-operator" + assert response.json()["support_operator"] is True + + +def test_me_defaults_support_operator_to_false( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "support_operator_subjects", "support-operator") + app.dependency_overrides[get_current_user] = lambda: _current_user( + "customer-owner" + ) + + response = TestClient(app).get("/api/me") + + assert response.status_code == 200 + assert response.json()["subject"] == "customer-owner" + assert response.json()["support_operator"] is False diff --git a/backend/tests/test_auth_security.py b/backend/tests/test_auth_security.py index 9f93eb4f..1c404fb0 100644 --- a/backend/tests/test_auth_security.py +++ b/backend/tests/test_auth_security.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import datetime as dt import uuid import pytest @@ -8,6 +9,7 @@ from starlette.requests import Request from app import auth +from app.models import BillingEvent from app.settings import settings @@ -538,6 +540,214 @@ async def test_ensure_user_reuses_short_lived_cache() -> None: auth._user_cache.clear() +@pytest.mark.asyncio +async def test_get_current_user_rejects_deactivated_subject_before_db( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def fake_subject(_request: Request) -> tuple[str, str | None]: + return "subject-1", "User One" + + class NoDbSession: + def begin(self) -> object: + raise AssertionError("deactivated subjects must be rejected before DB access") + + monkeypatch.setattr(auth, "_get_subject_from_request", fake_subject) + monkeypatch.setattr( + settings, + "account_deactivated_subjects", + "subject-1, subject-2", + raising=False, + ) + monkeypatch.setattr( + settings, + "account_reactivation_url", + "https://billing.example.com/reactivate", + raising=False, + ) + monkeypatch.setattr( + settings, + "billing_support_url", + "https://support.example.com", + raising=False, + ) + + with pytest.raises(HTTPException) as exc_info: + await auth.get_current_user(make_request(), NoDbSession()) # type: ignore[arg-type] + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "account deactivated" + assert exc_info.value.headers == { + "X-Account-Status": "deactivated", + "X-Account-Reactivation-Url": "https://billing.example.com/reactivate", + "X-Billing-Support-Url": "https://support.example.com", + } + + +@pytest.mark.asyncio +async def test_get_current_user_rejects_contract_deactivated_event( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def fake_subject(_request: Request) -> tuple[str, str | None]: + return "customer-owner", "Customer Owner" + + class FakeContractResult: + def scalar_one_or_none(self) -> BillingEvent: + return BillingEvent( + billing_event_uuid=uuid.uuid4(), + provider="manual-contract", + provider_event_id="contract-suspend-1", + event_type="contract.suspended", + subject="customer-owner", + target_plan="enterprise", + event_status="recorded", + occurred_at=dt.datetime(2026, 7, 2, tzinfo=dt.timezone.utc), + received_at=dt.datetime(2026, 7, 2, 1, tzinfo=dt.timezone.utc), + metadata_json={}, + ) + + class FakeBegin: + async def __aenter__(self) -> None: + return None + + async def __aexit__(self, *_args: object) -> None: + return None + + class FakeContractSession: + async def execute(self, _statement: object) -> FakeContractResult: + return FakeContractResult() + + def begin(self) -> FakeBegin: + return FakeBegin() + + monkeypatch.setattr(auth, "_get_subject_from_request", fake_subject) + monkeypatch.setattr(settings, "account_deactivated_subjects", "", raising=False) + monkeypatch.setattr( + settings, + "billing_contract_state_events_enabled", + True, + raising=False, + ) + monkeypatch.setattr( + settings, + "billing_contract_deactivated_event_types", + "contract.suspended", + raising=False, + ) + monkeypatch.setattr( + settings, + "billing_contract_active_event_types", + "contract.reactivated", + raising=False, + ) + monkeypatch.setattr( + settings, + "account_reactivation_url", + "https://billing.example.com/reactivate", + raising=False, + ) + monkeypatch.setattr( + settings, + "billing_support_url", + "https://support.example.com", + raising=False, + ) + + with pytest.raises(HTTPException) as exc_info: + await auth.get_current_user(make_request(), FakeContractSession()) # type: ignore[arg-type] + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "account deactivated" + assert exc_info.value.headers == { + "X-Account-Status": "deactivated", + "X-Account-Reactivation-Url": "https://billing.example.com/reactivate", + "X-Billing-Support-Url": "https://support.example.com", + } + + +@pytest.mark.asyncio +async def test_get_current_user_allows_contract_reactivated_event( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def fake_subject(_request: Request) -> tuple[str, str | None]: + return "subject-1", "User One" + + class FakeMixedResult: + def __init__(self, value: object) -> None: + self.value = value + + def scalar_one_or_none(self) -> object: + return self.value + + def scalars(self) -> _FakeScalarResult: + return _FakeScalarResult(self.value) + + class FakeBegin: + async def __aenter__(self) -> None: + return None + + async def __aexit__(self, *_args: object) -> None: + return None + + class FakeContractSession: + def __init__(self) -> None: + self.execute_calls = 0 + + async def execute(self, _statement: object) -> FakeMixedResult: + self.execute_calls += 1 + if self.execute_calls == 1: + return FakeMixedResult( + BillingEvent( + billing_event_uuid=uuid.uuid4(), + provider="manual-contract", + provider_event_id="contract-reactivate-1", + event_type="contract.reactivated", + subject="subject-1", + target_plan="enterprise", + event_status="recorded", + occurred_at=dt.datetime(2026, 7, 2, tzinfo=dt.timezone.utc), + received_at=dt.datetime( + 2026, 7, 2, 1, tzinfo=dt.timezone.utc + ), + metadata_json={}, + ) + ) + return FakeMixedResult(_ExistingUser()) + + def begin(self) -> FakeBegin: + return FakeBegin() + + auth._user_cache.clear() + try: + session = FakeContractSession() + monkeypatch.setattr(auth, "_get_subject_from_request", fake_subject) + monkeypatch.setattr(settings, "account_deactivated_subjects", "", raising=False) + monkeypatch.setattr( + settings, + "billing_contract_state_events_enabled", + True, + raising=False, + ) + monkeypatch.setattr( + settings, + "billing_contract_deactivated_event_types", + "contract.suspended", + raising=False, + ) + monkeypatch.setattr( + settings, + "billing_contract_active_event_types", + "contract.reactivated", + raising=False, + ) + + user = await auth.get_current_user(make_request(), session) # type: ignore[arg-type] + + assert user.subject == "subject-1" + assert session.execute_calls == 2 + finally: + auth._user_cache.clear() + + @pytest.mark.asyncio async def test_try_get_subject_for_rate_limit_error_path(): """Verify try_get_subject_for_rate_limit returns None on auth failure.""" diff --git a/backend/tests/test_billing_usage.py b/backend/tests/test_billing_usage.py new file mode 100644 index 00000000..5399b171 --- /dev/null +++ b/backend/tests/test_billing_usage.py @@ -0,0 +1,1207 @@ +from __future__ import annotations + +import datetime as dt +import hashlib +import hmac +import json +import uuid +from collections.abc import Iterator + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from prometheus_client import REGISTRY + +from app.api.billing import router +from app.auth import CurrentUser, get_current_user +from app.db import get_read_session, get_session +from app.models import BillingEvent, ShareLink, UserAccount +from app.settings import settings + +app = FastAPI() +app.include_router(router) + + +class FakeResult: + def __init__(self, value: int) -> None: + self.value = value + + def scalar_one(self) -> int: + return self.value + + +class FakeSession: + def __init__(self, counts: list[int]) -> None: + self.counts = counts + self.statement_count = 0 + + async def execute(self, stmt: object) -> FakeResult: + del stmt + value = self.counts[self.statement_count] + self.statement_count += 1 + return FakeResult(value) + + +class FakeLlmUsageRow: + def __init__(self, values: dict[str, int]) -> None: + self._mapping = values + + +class FakeLlmUsageResult: + def __init__(self, values: dict[str, int]) -> None: + self.values = values + + def one(self) -> FakeLlmUsageRow: + return FakeLlmUsageRow(self.values) + + +class FakeLlmUsageSession: + def __init__(self, values: dict[str, int]) -> None: + self.values = values + self.statement_count = 0 + + async def execute(self, stmt: object) -> FakeLlmUsageResult: + del stmt + self.statement_count += 1 + return FakeLlmUsageResult(self.values) + + +class FakeBillingEventResult: + def __init__(self, event: BillingEvent | None) -> None: + self.event = event + + def scalar_one_or_none(self) -> BillingEvent | None: + return self.event + + +class FakeBillingEventSession: + def __init__(self, existing_event: BillingEvent | None = None) -> None: + self.existing_event = existing_event + self.added_event: BillingEvent | None = None + self.committed = False + self.rolled_back = False + + async def execute(self, stmt: object) -> FakeBillingEventResult: + del stmt + return FakeBillingEventResult(self.existing_event) + + def add(self, event: BillingEvent) -> None: + self.added_event = event + + async def commit(self) -> None: + self.committed = True + + async def rollback(self) -> None: + self.rolled_back = True + + +def fake_billing_event_session() -> FakeBillingEventSession: + return FakeBillingEventSession() + + +class FakeScalars: + def __init__(self, values: list[object]) -> None: + self.values = values + + def all(self) -> list[object]: + return self.values + + +class FakeSupportResult: + def __init__( + self, + value: object | None = None, + values: list[object] | None = None, + mapping: dict[str, int] | None = None, + ) -> None: + self.value = value + self.values = values or [] + self.mapping = mapping or {} + + def scalar_one(self) -> object: + return self.value + + def scalar_one_or_none(self) -> object | None: + return self.value + + def one(self) -> FakeLlmUsageRow: + return FakeLlmUsageRow(self.mapping) + + def scalars(self) -> FakeScalars: + return FakeScalars(self.values) + + def all(self) -> list[object]: + return self.values + + +class FakeSupportSession: + def __init__( + self, + account: UserAccount | None, + counts: list[int], + events: list[BillingEvent], + share_links: list[ShareLink] | None = None, + contract_event: BillingEvent | None = None, + llm_usage_counts: dict[str, int] | None = None, + seat_deprovisioning_candidates: list[tuple[str, int]] | None = None, + ) -> None: + self.account = account + self.counts = counts + self.events = events + self.share_links = share_links or [] + self.contract_event = contract_event + self.seat_deprovisioning_candidates = seat_deprovisioning_candidates + self.llm_usage_counts = llm_usage_counts or { + "request_count": 0, + "success_count": 0, + "failure_count": 0, + "quota_exceeded_count": 0, + "input_chars": 0, + "output_chars": 0, + } + self.statement_count = 0 + + async def execute(self, stmt: object) -> FakeSupportResult: + del stmt + self.statement_count += 1 + if self.statement_count == 1: + return FakeSupportResult(self.account) + if 2 <= self.statement_count <= 7: + return FakeSupportResult(self.counts[self.statement_count - 2]) + contract_index = 8 if self.contract_event is not None else None + share_index = 9 if self.contract_event is not None else 8 + events_index = share_index + 1 + llm_index = events_index + 1 + if self.statement_count == contract_index: + return FakeSupportResult(self.contract_event) + if self.statement_count == share_index: + return FakeSupportResult(values=list(self.share_links)) + if self.statement_count == events_index: + return FakeSupportResult(values=list(self.events)) + candidate_index = ( + events_index + 1 + if self.seat_deprovisioning_candidates is not None + else None + ) + llm_index = events_index + ( + 2 if self.seat_deprovisioning_candidates is not None else 1 + ) + if self.statement_count == candidate_index: + return FakeSupportResult( + values=list(self.seat_deprovisioning_candidates or []), + ) + if self.statement_count == llm_index: + return FakeSupportResult(mapping=self.llm_usage_counts) + raise AssertionError(f"unexpected support query #{self.statement_count}") + + +@pytest.fixture(autouse=True) +def clear_dependency_overrides() -> Iterator[None]: + yield + app.dependency_overrides.clear() + + +def fake_get_current_user() -> CurrentUser: + return CurrentUser( + user_account_uuid=uuid.uuid4(), + subject="customer-owner", + display_name="Customer Owner", + ) + + +def fake_get_support_operator() -> CurrentUser: + return CurrentUser( + user_account_uuid=uuid.uuid4(), + subject="support-operator", + display_name="Support Operator", + ) + + +def _signed_billing_event_body( + payload: dict[str, object], + *, + secret: str, +) -> tuple[bytes, str]: + body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + signature = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() + return body, f"sha256={signature}" + + +def _billing_metric_value( + *, + provider: str, + event_type: str, + outcome: str, +) -> float: + value = REGISTRY.get_sample_value( + "billing_events_total", + {"provider": provider, "event_type": event_type, "outcome": outcome}, + ) + return float(value or 0.0) + + +def test_billing_usage_returns_owned_project_scope_counts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = FakeSession([2, 5, 3, 8, 4, 1]) + monkeypatch.setattr(settings, "license_mode", "required") + monkeypatch.setattr(settings, "license_key", None) + monkeypatch.setattr(settings, "license_public_key", "x" * 44) + monkeypatch.setattr(settings, "billing_max_projects_per_user", 10) + monkeypatch.setattr(settings, "billing_max_connections_per_project", 20) + monkeypatch.setattr(settings, "billing_max_snapshots_per_project", 30) + monkeypatch.setattr(settings, "billing_max_share_links_per_project", 40) + monkeypatch.setattr(settings, "billing_portal_url", "https://billing.example.com") + monkeypatch.setattr(settings, "billing_support_url", "https://support.example.com") + monkeypatch.setattr( + settings, + "account_reactivation_url", + "https://billing.example.com/reactivate", + ) + + app.dependency_overrides[get_current_user] = fake_get_current_user + app.dependency_overrides[get_read_session] = lambda: session + + response = TestClient(app).get("/api/billing/usage") + + assert response.status_code == 200 + assert response.json() == { + "scope": "owned_projects", + "license_mode": "required", + "license_verifier": "signed_token", + "project_count": 2, + "seat_count": 5, + "connection_count": 3, + "snapshot_count": 8, + "share_link_count": 4, + "active_share_link_count": 1, + "project_limit": 10, + "connection_limit": 20, + "snapshot_limit": 30, + "share_link_limit": 40, + "account_status": "active", + "billing_portal_url": "https://billing.example.com", + "billing_support_url": "https://support.example.com", + "account_reactivation_url": "https://billing.example.com/reactivate", + } + assert session.statement_count == 6 + + +def test_billing_usage_reports_no_license_verifier_when_unconfigured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = FakeSession([0, 0, 0, 0, 0, 0]) + monkeypatch.setattr(settings, "license_mode", "off") + monkeypatch.setattr(settings, "license_key", None) + monkeypatch.setattr(settings, "license_public_key", None) + monkeypatch.setattr(settings, "billing_max_projects_per_user", 0) + monkeypatch.setattr(settings, "billing_max_connections_per_project", 0) + monkeypatch.setattr(settings, "billing_max_snapshots_per_project", 0) + monkeypatch.setattr(settings, "billing_max_share_links_per_project", 0) + monkeypatch.setattr(settings, "billing_portal_url", None) + monkeypatch.setattr(settings, "billing_support_url", None) + monkeypatch.setattr(settings, "account_reactivation_url", None) + + app.dependency_overrides[get_current_user] = fake_get_current_user + app.dependency_overrides[get_read_session] = lambda: session + + response = TestClient(app).get("/api/billing/usage") + + assert response.status_code == 200 + assert response.json()["license_verifier"] == "none" + assert response.json()["project_count"] == 0 + assert response.json()["account_status"] == "active" + assert response.json()["billing_portal_url"] is None + assert response.json()["billing_support_url"] is None + assert response.json()["account_reactivation_url"] is None + + +def test_billing_llm_usage_returns_monthly_account_attribution() -> None: + session = FakeLlmUsageSession( + { + "request_count": 4, + "success_count": 2, + "failure_count": 2, + "quota_exceeded_count": 1, + "input_chars": 12000, + "output_chars": 3400, + } + ) + app.dependency_overrides[get_current_user] = fake_get_current_user + app.dependency_overrides[get_read_session] = lambda: session + + response = TestClient(app).get("/api/billing/llm-usage?month=2026-07") + + assert response.status_code == 200 + assert response.json() == { + "scope": "account", + "month": "2026-07", + "request_count": 4, + "success_count": 2, + "failure_count": 2, + "quota_exceeded_count": 1, + "input_chars": 12000, + "output_chars": 3400, + } + assert session.statement_count == 1 + + +def test_billing_llm_usage_rejects_invalid_month() -> None: + app.dependency_overrides[get_current_user] = fake_get_current_user + app.dependency_overrides[get_read_session] = lambda: FakeLlmUsageSession({}) + + response = TestClient(app).get("/api/billing/llm-usage?month=2026-13") + + assert response.status_code == 422 + assert response.json()["detail"] == "month must use YYYY-MM" + + +def test_billing_plan_change_returns_portal_url_with_target_plan( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + settings, + "billing_portal_url", + "https://billing.example.com/portal?source=pg-erd", + ) + monkeypatch.setattr(settings, "billing_support_url", "https://support.example.com") + + app.dependency_overrides[get_current_user] = fake_get_current_user + + response = TestClient(app).post( + "/api/billing/plan-change", + json={"target_plan": "enterprise-plus"}, + ) + + assert response.status_code == 200 + assert response.json() == { + "action": "portal_redirect", + "target_plan": "enterprise-plus", + "billing_portal_url": ( + "https://billing.example.com/portal?" + "source=pg-erd&target_plan=enterprise-plus" + ), + "billing_support_url": "https://support.example.com", + "message": "Open the billing portal to request or complete this plan change.", + } + + +def test_billing_plan_change_rejects_unknown_catalog_plan( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "billing_portal_url", "https://billing.example.com") + monkeypatch.setattr(settings, "billing_support_url", "https://support.example.com") + monkeypatch.setattr(settings, "billing_allowed_plans", "team,enterprise") + + app.dependency_overrides[get_current_user] = fake_get_current_user + + response = TestClient(app).post( + "/api/billing/plan-change", + json={"target_plan": "unsupported-plan"}, + ) + + assert response.status_code == 422 + assert response.json()["detail"] == "target plan is not in configured billing catalog" + + +def test_billing_plan_change_falls_back_to_support_when_portal_unconfigured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "billing_portal_url", None) + monkeypatch.setattr(settings, "billing_support_url", "https://support.example.com") + + app.dependency_overrides[get_current_user] = fake_get_current_user + + response = TestClient(app).post( + "/api/billing/plan-change", + json={"target_plan": "team"}, + ) + + assert response.status_code == 200 + assert response.json() == { + "action": "contact_support", + "target_plan": "team", + "billing_portal_url": None, + "billing_support_url": "https://support.example.com", + "message": "Contact billing support to request or complete this plan change.", + } + + +def test_billing_plan_change_requires_portal_or_support_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "billing_portal_url", None) + monkeypatch.setattr(settings, "billing_support_url", None) + + app.dependency_overrides[get_current_user] = fake_get_current_user + + response = TestClient(app).post( + "/api/billing/plan-change", + json={"target_plan": "team"}, + ) + + assert response.status_code == 503 + assert response.json()["detail"] == "billing plan change path is not configured" + + +def test_billing_checkout_returns_checkout_url_with_target_plan( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + settings, + "billing_checkout_url", + "https://billing.example.com/checkout?source=pg-erd", + ) + monkeypatch.setattr(settings, "billing_support_url", "https://support.example.com") + + app.dependency_overrides[get_current_user] = fake_get_current_user + + response = TestClient(app).post( + "/api/billing/checkout", + json={"target_plan": "enterprise-plus"}, + ) + + assert response.status_code == 200 + assert response.json() == { + "action": "checkout_redirect", + "target_plan": "enterprise-plus", + "billing_checkout_url": ( + "https://billing.example.com/checkout?" + "source=pg-erd&target_plan=enterprise-plus" + ), + "billing_support_url": "https://support.example.com", + "message": "Open checkout to start this plan.", + } + + +def test_billing_checkout_rejects_unknown_catalog_plan( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "billing_checkout_url", "https://billing.example.com") + monkeypatch.setattr(settings, "billing_support_url", "https://support.example.com") + monkeypatch.setattr(settings, "billing_allowed_plans", "team,enterprise") + + app.dependency_overrides[get_current_user] = fake_get_current_user + + response = TestClient(app).post( + "/api/billing/checkout", + json={"target_plan": "unsupported-plan"}, + ) + + assert response.status_code == 422 + assert response.json()["detail"] == "target plan is not in configured billing catalog" + + +def test_billing_checkout_falls_back_to_support_when_checkout_unconfigured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "billing_checkout_url", None) + monkeypatch.setattr(settings, "billing_support_url", "https://support.example.com") + + app.dependency_overrides[get_current_user] = fake_get_current_user + + response = TestClient(app).post( + "/api/billing/checkout", + json={"target_plan": "team"}, + ) + + assert response.status_code == 200 + assert response.json() == { + "action": "contact_support", + "target_plan": "team", + "billing_checkout_url": None, + "billing_support_url": "https://support.example.com", + "message": "Contact billing support to start this plan.", + } + + +def test_billing_event_requires_configured_secret( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "billing_webhook_secret", None) + app.dependency_overrides[get_session] = fake_billing_event_session + + response = TestClient(app).post( + "/api/billing/events", + headers={"X-BILLING-WEBHOOK-SECRET": "provider-secret"}, + json={ + "provider": "stripe", + "provider_event_id": "evt_1", + "event_type": "subscription.updated", + "subject": "customer-owner", + }, + ) + + assert response.status_code == 503 + assert response.json()["detail"] == "billing webhook secret is not configured" + + +def test_billing_event_rejects_invalid_secret( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "billing_webhook_secret", "provider-secret") + app.dependency_overrides[get_session] = fake_billing_event_session + before = _billing_metric_value( + provider="stripe", + event_type="subscription.updated", + outcome="rejected_auth", + ) + + response = TestClient(app).post( + "/api/billing/events", + headers={"X-BILLING-WEBHOOK-SECRET": "wrong-secret"}, + json={ + "provider": "stripe", + "provider_event_id": "evt_1", + "event_type": "subscription.updated", + "subject": "customer-owner", + }, + ) + + assert response.status_code == 401 + assert response.json()["detail"] == "invalid billing webhook secret" + assert ( + _billing_metric_value( + provider="stripe", + event_type="subscription.updated", + outcome="rejected_auth", + ) + == before + 1 + ) + + +def test_billing_event_records_and_redacts_sensitive_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = FakeBillingEventSession() + monkeypatch.setattr(settings, "billing_webhook_secret", "provider-secret") + app.dependency_overrides[get_session] = lambda: session + before = _billing_metric_value( + provider="stripe", + event_type="subscription.updated", + outcome="recorded", + ) + + response = TestClient(app).post( + "/api/billing/events", + headers={"X-BILLING-WEBHOOK-SECRET": "provider-secret"}, + json={ + "provider": "stripe", + "provider_event_id": "evt_1", + "event_type": "subscription.updated", + "subject": "customer-owner", + "target_plan": "enterprise", + "occurred_at": "2026-07-02T01:02:03Z", + "metadata": { + "invoice_id": "in_123", + "api_key": "sk_live_secret", + "nested": {"client_secret": "seti_secret"}, + }, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["action"] == "recorded" + assert payload["provider"] == "stripe" + assert payload["provider_event_id"] == "evt_1" + assert payload["event_type"] == "subscription.updated" + assert payload["subject"] == "customer-owner" + assert payload["target_plan"] == "enterprise" + assert payload["status"] == "recorded" + assert session.committed is True + assert session.added_event is not None + assert session.added_event.metadata_json == { + "invoice_id": "in_123", + "api_key": "[redacted]", + "nested": {"client_secret": "[redacted]"}, + } + assert ( + _billing_metric_value( + provider="stripe", + event_type="subscription.updated", + outcome="recorded", + ) + == before + 1 + ) + + +def test_billing_event_rejects_unknown_catalog_plan( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = FakeBillingEventSession() + monkeypatch.setattr(settings, "billing_webhook_secret", "provider-secret") + monkeypatch.setattr(settings, "billing_webhook_signature_secret", None) + monkeypatch.setattr(settings, "billing_allowed_plans", "team,enterprise") + app.dependency_overrides[get_session] = lambda: session + before = _billing_metric_value( + provider="stripe", + event_type="subscription.updated", + outcome="rejected_catalog", + ) + + response = TestClient(app).post( + "/api/billing/events", + headers={"X-BILLING-WEBHOOK-SECRET": "provider-secret"}, + json={ + "provider": "stripe", + "provider_event_id": "evt_unknown_plan", + "event_type": "subscription.updated", + "subject": "customer-owner", + "target_plan": "unsupported-plan", + }, + ) + + assert response.status_code == 422 + assert response.json()["detail"] == "target plan is not in configured billing catalog" + assert session.added_event is None + assert session.committed is False + assert ( + _billing_metric_value( + provider="stripe", + event_type="subscription.updated", + outcome="rejected_catalog", + ) + == before + 1 + ) + + +def test_billing_event_normalizes_provider_event_type_alias( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = FakeBillingEventSession() + monkeypatch.setattr(settings, "billing_webhook_secret", "provider-secret") + monkeypatch.setattr(settings, "billing_webhook_signature_secret", None) + monkeypatch.setattr( + settings, + "billing_event_type_aliases", + "stripe:customer.subscription.deleted=contract.suspended", + ) + app.dependency_overrides[get_session] = lambda: session + before = _billing_metric_value( + provider="stripe", + event_type="contract.suspended", + outcome="recorded", + ) + + response = TestClient(app).post( + "/api/billing/events", + headers={"X-BILLING-WEBHOOK-SECRET": "provider-secret"}, + json={ + "provider": "stripe", + "provider_event_id": "evt_subscription_deleted_1", + "event_type": "customer.subscription.deleted", + "subject": "customer-owner", + "target_plan": "enterprise", + "metadata": {"raw_event_type": "caller-supplied-value"}, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["action"] == "recorded" + assert payload["event_type"] == "contract.suspended" + assert session.committed is True + assert session.added_event is not None + assert session.added_event.event_type == "contract.suspended" + assert session.added_event.metadata_json == { + "raw_event_type": "customer.subscription.deleted" + } + assert ( + _billing_metric_value( + provider="stripe", + event_type="contract.suspended", + outcome="recorded", + ) + == before + 1 + ) + + +def test_billing_event_accepts_hmac_signature_without_shared_header_secret( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = FakeBillingEventSession() + monkeypatch.setattr(settings, "billing_webhook_secret", None) + monkeypatch.setattr(settings, "billing_webhook_signature_secret", "signing-secret") + app.dependency_overrides[get_session] = lambda: session + body, signature = _signed_billing_event_body( + { + "provider": "stripe", + "provider_event_id": "evt_signed_1", + "event_type": "subscription.updated", + "subject": "customer-owner", + }, + secret="signing-secret", + ) + + response = TestClient(app).post( + "/api/billing/events", + headers={ + "content-type": "application/json", + "X-BILLING-WEBHOOK-SIGNATURE": signature, + }, + content=body, + ) + + assert response.status_code == 200 + assert response.json()["action"] == "recorded" + assert response.json()["provider_event_id"] == "evt_signed_1" + assert session.committed is True + + +def test_billing_event_rejects_invalid_hmac_signature( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = FakeBillingEventSession() + monkeypatch.setattr(settings, "billing_webhook_secret", None) + monkeypatch.setattr(settings, "billing_webhook_signature_secret", "signing-secret") + app.dependency_overrides[get_session] = lambda: session + + response = TestClient(app).post( + "/api/billing/events", + headers={ + "content-type": "application/json", + "X-BILLING-WEBHOOK-SIGNATURE": "sha256=bad", + }, + json={ + "provider": "stripe", + "provider_event_id": "evt_signed_1", + "event_type": "subscription.updated", + "subject": "customer-owner", + }, + ) + + assert response.status_code == 401 + assert response.json()["detail"] == "invalid billing webhook signature" + assert session.added_event is None + assert session.committed is False + + +def test_billing_event_ignores_duplicate_provider_event( + monkeypatch: pytest.MonkeyPatch, +) -> None: + existing_event = BillingEvent( + billing_event_uuid=uuid.uuid4(), + provider="stripe", + provider_event_id="evt_1", + event_type="subscription.updated", + subject="customer-owner", + target_plan="enterprise", + event_status="recorded", + occurred_at=dt.datetime(2026, 7, 2, tzinfo=dt.timezone.utc), + received_at=dt.datetime(2026, 7, 2, 1, tzinfo=dt.timezone.utc), + metadata_json={}, + ) + session = FakeBillingEventSession(existing_event=existing_event) + monkeypatch.setattr(settings, "billing_webhook_secret", "provider-secret") + app.dependency_overrides[get_session] = lambda: session + before = _billing_metric_value( + provider="stripe", + event_type="subscription.updated", + outcome="duplicate", + ) + + response = TestClient(app).post( + "/api/billing/events", + headers={"X-BILLING-WEBHOOK-SECRET": "provider-secret"}, + json={ + "provider": "stripe", + "provider_event_id": "evt_1", + "event_type": "subscription.updated", + "subject": "customer-owner", + "target_plan": "enterprise", + }, + ) + + assert response.status_code == 200 + assert response.json()["action"] == "duplicate" + assert uuid.UUID(response.json()["billing_event_uuid"]) == ( + existing_event.billing_event_uuid + ) + assert session.added_event is None + assert session.committed is False + assert ( + _billing_metric_value( + provider="stripe", + event_type="subscription.updated", + outcome="duplicate", + ) + == before + 1 + ) + + +def test_support_account_billing_requires_support_operator( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "support_operator_subjects", "support-operator") + app.dependency_overrides[get_current_user] = fake_get_current_user + app.dependency_overrides[get_read_session] = lambda: FakeSupportSession( + account=None, + counts=[], + events=[], + ) + + response = TestClient(app).get( + "/api/billing/support/account", + params={"subject": "customer-owner"}, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "support operator role required" + + +def test_support_account_billing_returns_read_only_account_diagnostics( + monkeypatch: pytest.MonkeyPatch, +) -> None: + customer_uuid = uuid.uuid4() + project_uuid = uuid.uuid4() + share_link_uuid = uuid.uuid4() + account = UserAccount( + user_account_uuid=customer_uuid, + oidc_subject="customer-owner", + display_name="Customer Owner", + ) + share_link = ShareLink( + share_link_uuid=share_link_uuid, + project_space_uuid=project_uuid, + created_by_user_uuid=customer_uuid, + permission_kind="viewer", + expires_at=dt.datetime(2099, 7, 2, tzinfo=dt.timezone.utc), + created_at=dt.datetime(2026, 7, 2, 2, tzinfo=dt.timezone.utc), + ) + event = BillingEvent( + billing_event_uuid=uuid.uuid4(), + provider="stripe", + provider_event_id="evt_1", + event_type="subscription.updated", + subject="customer-owner", + target_plan="enterprise", + event_status="recorded", + occurred_at=dt.datetime(2026, 7, 2, tzinfo=dt.timezone.utc), + received_at=dt.datetime(2026, 7, 2, 1, tzinfo=dt.timezone.utc), + metadata_json={ + "api_key": "[redacted]", + "invoice_id": "in_123", + "customer": {"id": "cus_123"}, + }, + ) + session = FakeSupportSession( + account=account, + counts=[2, 5, 3, 8, 4, 1], + events=[event], + share_links=[share_link], + llm_usage_counts={ + "request_count": 12, + "success_count": 10, + "failure_count": 2, + "quota_exceeded_count": 1, + "input_chars": 2048, + "output_chars": 512, + }, + ) + monkeypatch.setattr(settings, "support_operator_subjects", "support-operator") + monkeypatch.setattr( + "app.api.billing.utcnow", + lambda: dt.datetime(2026, 7, 2, tzinfo=dt.timezone.utc), + ) + monkeypatch.setattr(settings, "account_deactivated_subjects", "") + monkeypatch.setattr(settings, "license_mode", "required") + monkeypatch.setattr(settings, "license_key", None) + monkeypatch.setattr(settings, "license_public_key", "x" * 44) + monkeypatch.setattr(settings, "billing_portal_url", "https://billing.example.com") + monkeypatch.setattr(settings, "billing_support_url", "https://support.example.com") + monkeypatch.setattr( + settings, + "account_reactivation_url", + "https://billing.example.com/reactivate", + ) + app.dependency_overrides[get_current_user] = fake_get_support_operator + app.dependency_overrides[get_read_session] = lambda: session + + response = TestClient(app).get( + "/api/billing/support/account", + params={"subject": "customer-owner"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["subject"] == "customer-owner" + assert payload["user_account_uuid"] == str(customer_uuid) + assert payload["account_status"] == "active" + assert payload["license_mode"] == "required" + assert payload["license_verifier"] == "signed_token" + assert payload["billing_portal_url"] == "https://billing.example.com" + assert payload["billing_support_url"] == "https://support.example.com" + assert payload["account_reactivation_url"] == ( + "https://billing.example.com/reactivate" + ) + assert payload["project_count"] == 2 + assert payload["seat_count"] == 5 + assert payload["connection_count"] == 3 + assert payload["snapshot_count"] == 8 + assert payload["share_link_count"] == 4 + assert payload["active_share_link_count"] == 1 + assert payload["llm_usage_current_month"] == { + "scope": "account", + "month": "2026-07", + "request_count": 12, + "success_count": 10, + "failure_count": 2, + "quota_exceeded_count": 1, + "input_chars": 2048, + "output_chars": 512, + } + assert payload["recent_share_links"] == [ + { + "share_link_uuid": str(share_link_uuid), + "project_space_uuid": str(project_uuid), + "permission_kind": "viewer", + "status": "active", + "expires_at": "2099-07-02T00:00:00Z", + "created_at": "2026-07-02T02:00:00Z", + } + ] + assert payload["recent_billing_events"] == [ + { + "billing_event_uuid": str(event.billing_event_uuid), + "provider": "stripe", + "provider_event_id": "evt_1", + "event_type": "subscription.updated", + "target_plan": "enterprise", + "status": "recorded", + "occurred_at": "2026-07-02T00:00:00Z", + "received_at": "2026-07-02T01:00:00Z", + "metadata_summary": [ + {"key": "api_key", "value": "[redacted]"}, + {"key": "invoice_id", "value": "in_123"}, + {"key": "customer.id", "value": "cus_123"}, + ], + } + ] + assert payload["billing_entitlement"] == { + "plan": "enterprise", + "seat_count": None, + "source_provider": "stripe", + "source_provider_event_id": "evt_1", + "source_event_type": "subscription.updated", + "source_occurred_at": "2026-07-02T00:00:00Z", + } + assert payload["billing_seat_reconciliation"] == { + "status": "not_configured", + "contracted_seat_count": None, + "active_seat_count": 5, + "seats_over_limit": 0, + "deprovisioning_required": False, + "deprovisioning_candidates": [], + } + assert session.statement_count == 10 + + +def test_support_account_billing_derives_entitlement_from_latest_active_event( + monkeypatch: pytest.MonkeyPatch, +) -> None: + customer_uuid = uuid.uuid4() + account = UserAccount( + user_account_uuid=customer_uuid, + oidc_subject="customer-owner", + display_name="Customer Owner", + ) + older_event = BillingEvent( + billing_event_uuid=uuid.uuid4(), + provider="stripe", + provider_event_id="evt_old", + event_type="invoice.paid", + subject="customer-owner", + target_plan="team", + event_status="recorded", + occurred_at=dt.datetime(2026, 7, 1, tzinfo=dt.timezone.utc), + received_at=dt.datetime(2026, 7, 1, 1, tzinfo=dt.timezone.utc), + metadata_json={"seat_count": 5}, + ) + latest_event = BillingEvent( + billing_event_uuid=uuid.uuid4(), + provider="stripe", + provider_event_id="evt_latest", + event_type="subscription.updated", + subject="customer-owner", + target_plan="enterprise", + event_status="recorded", + occurred_at=dt.datetime(2026, 7, 2, tzinfo=dt.timezone.utc), + received_at=dt.datetime(2026, 7, 2, 1, tzinfo=dt.timezone.utc), + metadata_json={"seats": "25"}, + ) + session = FakeSupportSession( + account=account, + counts=[1, 3, 2, 4, 1, 1], + events=[latest_event, older_event], + ) + monkeypatch.setattr(settings, "support_operator_subjects", "support-operator") + monkeypatch.setattr(settings, "account_deactivated_subjects", "") + monkeypatch.setattr( + settings, + "billing_entitlement_event_types", + "invoice.paid,subscription.updated", + ) + app.dependency_overrides[get_current_user] = fake_get_support_operator + app.dependency_overrides[get_read_session] = lambda: session + + response = TestClient(app).get( + "/api/billing/support/account", + params={"subject": "customer-owner"}, + ) + + assert response.status_code == 200 + assert response.json()["billing_entitlement"] == { + "plan": "enterprise", + "seat_count": 25, + "source_provider": "stripe", + "source_provider_event_id": "evt_latest", + "source_event_type": "subscription.updated", + "source_occurred_at": "2026-07-02T00:00:00Z", + } + assert response.json()["billing_seat_reconciliation"] == { + "status": "within_limit", + "contracted_seat_count": 25, + "active_seat_count": 3, + "seats_over_limit": 0, + "deprovisioning_required": False, + "deprovisioning_candidates": [], + } + + +def test_support_account_billing_reports_over_limit_seat_reconciliation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + customer_uuid = uuid.uuid4() + account = UserAccount( + user_account_uuid=customer_uuid, + oidc_subject="customer-owner", + display_name="Customer Owner", + ) + event = BillingEvent( + billing_event_uuid=uuid.uuid4(), + provider="stripe", + provider_event_id="evt_over_limit", + event_type="subscription.updated", + subject="customer-owner", + target_plan="enterprise", + event_status="recorded", + occurred_at=dt.datetime(2026, 7, 2, tzinfo=dt.timezone.utc), + received_at=dt.datetime(2026, 7, 2, 1, tzinfo=dt.timezone.utc), + metadata_json={"seat_count": 3}, + ) + session = FakeSupportSession( + account=account, + counts=[1, 5, 2, 0, 0, 0], + events=[event], + seat_deprovisioning_candidates=[ + ("member-a", 1), + ("member-b", 2), + ], + ) + monkeypatch.setattr(settings, "support_operator_subjects", "support-operator") + monkeypatch.setattr(settings, "account_deactivated_subjects", "") + monkeypatch.setattr( + settings, + "billing_entitlement_event_types", + "subscription.updated", + ) + app.dependency_overrides[get_current_user] = fake_get_support_operator + app.dependency_overrides[get_read_session] = lambda: session + + response = TestClient(app).get( + "/api/billing/support/account", + params={"subject": "customer-owner"}, + ) + + assert response.status_code == 200 + assert response.json()["billing_seat_reconciliation"] == { + "status": "over_limit", + "contracted_seat_count": 3, + "active_seat_count": 5, + "seats_over_limit": 2, + "deprovisioning_required": True, + "deprovisioning_candidates": [ + {"member_subject": "member-a", "project_count": 1}, + {"member_subject": "member-b", "project_count": 2}, + ], + } + assert session.statement_count == 11 + + +def test_support_account_billing_applies_contract_deactivation_event( + monkeypatch: pytest.MonkeyPatch, +) -> None: + customer_uuid = uuid.uuid4() + project_uuid = uuid.uuid4() + share_link_uuid = uuid.uuid4() + account = UserAccount( + user_account_uuid=customer_uuid, + oidc_subject="customer-owner", + display_name="Customer Owner", + ) + share_link = ShareLink( + share_link_uuid=share_link_uuid, + project_space_uuid=project_uuid, + created_by_user_uuid=customer_uuid, + permission_kind="viewer", + expires_at=dt.datetime(2020, 7, 2, tzinfo=dt.timezone.utc), + created_at=dt.datetime(2020, 7, 1, tzinfo=dt.timezone.utc), + ) + contract_event = BillingEvent( + billing_event_uuid=uuid.uuid4(), + provider="manual-contract", + provider_event_id="contract-suspend-1", + event_type="contract.suspended", + subject="customer-owner", + target_plan="enterprise", + event_status="recorded", + occurred_at=dt.datetime(2026, 7, 2, tzinfo=dt.timezone.utc), + received_at=dt.datetime(2026, 7, 2, 1, tzinfo=dt.timezone.utc), + metadata_json={}, + ) + session = FakeSupportSession( + account=account, + counts=[2, 5, 3, 8, 4, 1], + events=[contract_event], + share_links=[share_link], + contract_event=contract_event, + ) + monkeypatch.setattr(settings, "support_operator_subjects", "support-operator") + monkeypatch.setattr(settings, "account_deactivated_subjects", "") + monkeypatch.setattr(settings, "billing_contract_state_events_enabled", True) + monkeypatch.setattr( + settings, + "billing_contract_deactivated_event_types", + "contract.suspended", + ) + monkeypatch.setattr( + settings, + "billing_contract_active_event_types", + "contract.reactivated", + ) + monkeypatch.setattr(settings, "license_mode", "required") + monkeypatch.setattr(settings, "license_key", None) + monkeypatch.setattr(settings, "license_public_key", "x" * 44) + monkeypatch.setattr(settings, "billing_portal_url", "https://billing.example.com") + monkeypatch.setattr(settings, "billing_support_url", "https://support.example.com") + monkeypatch.setattr( + settings, + "account_reactivation_url", + "https://billing.example.com/reactivate", + ) + app.dependency_overrides[get_current_user] = fake_get_support_operator + app.dependency_overrides[get_read_session] = lambda: session + + response = TestClient(app).get( + "/api/billing/support/account", + params={"subject": "customer-owner"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["account_status"] == "deactivated" + assert payload["billing_entitlement"]["plan"] is None + assert payload["recent_share_links"][0]["status"] == "expired" + assert payload["recent_billing_events"][0]["event_type"] == "contract.suspended" + assert session.statement_count == 11 diff --git a/backend/tests/test_ddl_export.py b/backend/tests/test_ddl_export.py index f7d626b2..220bb1d3 100644 --- a/backend/tests/test_ddl_export.py +++ b/backend/tests/test_ddl_export.py @@ -27,6 +27,8 @@ def test_snapshot_export_preserves_table_tablespace() -> None: } ) + assert sql.startswith("-- Generated by pg-erd-cloud\n") + assert "MVP" not in sql assert 'CREATE TABLE IF NOT EXISTS "public"."events" (' in sql assert ') TABLESPACE "fast_space";' in sql @@ -190,7 +192,8 @@ def test_snapshot_export_can_target_snowflake_from_postgres_snapshot() -> None: target_dialect="snowflake", ) - assert "-- Generated by pg-erd-cloud (MVP) for Snowflake" in sql + assert "-- Generated by pg-erd-cloud for Snowflake" in sql + assert "MVP" not in sql assert 'CREATE SCHEMA IF NOT EXISTS "public";' in sql assert ' "id" NUMBER(10,0) NOT NULL,' in sql assert "nextval" not in sql diff --git a/backend/tests/test_dsn_redaction.py b/backend/tests/test_dsn_redaction.py new file mode 100644 index 00000000..c58f0d0b --- /dev/null +++ b/backend/tests/test_dsn_redaction.py @@ -0,0 +1,30 @@ +from app.dsn_redaction import redact_dsn_error_message, _password_candidates_from_dsn + + +def test_password_candidates_unsupported_scheme(): + dsn = "snowflake_invalid://user:supersecret123@example.com/db" + candidates = _password_candidates_from_dsn(dsn) + assert "supersecret123" in candidates + + +def test_password_candidates_query_params(): + dsn = "snowflake_invalid://user@example.com/db?password=anothersecret456&token=supersecret789" + candidates = _password_candidates_from_dsn(dsn) + assert "anothersecret456" in candidates + assert "supersecret789" in candidates + + +def test_redact_error_message_unsupported_scheme(): + dsn = "snowflake_invalid://user:supersecret123@example.com/db" + error = "Driver error: supersecret123 is invalid for host" + redacted = redact_dsn_error_message(error, dsn) + assert "supersecret123" not in redacted + assert "***" in redacted + + +def test_redact_error_message_standard(): + dsn = "postgresql://user:dbpass123@localhost/db" + error = "Connection failed. password: dbpass123." + redacted = redact_dsn_error_message(error, dsn) + assert "dbpass123" not in redacted + assert "***" in redacted diff --git a/backend/tests/test_license_gate.py b/backend/tests/test_license_gate.py new file mode 100644 index 00000000..150ace81 --- /dev/null +++ b/backend/tests/test_license_gate.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import base64 +import json +import time + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from fastapi import HTTPException + +from app.license_gate import require_active_license +from app.settings import settings + + +def _b64url(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") + + +def _public_key_value(private_key: Ed25519PrivateKey) -> str: + raw = private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + return _b64url(raw) + + +def _signed_license_token( + private_key: Ed25519PrivateKey, + payload: dict[str, object], +) -> str: + encoded_payload = _b64url( + json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + ) + signing_input = f"v1.{encoded_payload}".encode("ascii") + signature = private_key.sign(signing_input) + return f"v1.{encoded_payload}.{_b64url(signature)}" + + +def test_license_gate_skips_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "license_mode", "off") + monkeypatch.setattr(settings, "license_key", "x" * 64) + monkeypatch.setattr(settings, "license_revoked_token_ids", "") + monkeypatch.setattr(settings, "license_revoked_subjects", "") + + require_active_license(x_license_key=None) + + +def test_license_gate_rejects_missing_license_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "license_mode", "required") + monkeypatch.setattr(settings, "license_key", "x" * 32) + monkeypatch.setattr(settings, "license_public_key", None) + monkeypatch.setattr(settings, "license_revoked_token_ids", "") + monkeypatch.setattr(settings, "license_revoked_subjects", "") + + with pytest.raises(HTTPException) as exc_info: + require_active_license(x_license_key=None) + + assert exc_info.value.status_code == 403 + + +def test_license_gate_rejects_wrong_license_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "license_mode", "required") + monkeypatch.setattr(settings, "license_key", "x" * 32) + monkeypatch.setattr(settings, "license_public_key", None) + monkeypatch.setattr(settings, "license_revoked_token_ids", "") + monkeypatch.setattr(settings, "license_revoked_subjects", "") + + with pytest.raises(HTTPException) as exc_info: + require_active_license(x_license_key="wrong-key") + + assert exc_info.value.status_code == 403 + + +def test_license_gate_rejects_when_mode_is_required_but_key_not_configured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "license_mode", "required") + monkeypatch.setattr(settings, "license_key", None) + monkeypatch.setattr(settings, "license_public_key", None) + monkeypatch.setattr(settings, "license_revoked_token_ids", "") + monkeypatch.setattr(settings, "license_revoked_subjects", "") + + with pytest.raises(HTTPException) as exc_info: + require_active_license(x_license_key="anything") + + assert exc_info.value.status_code == 500 + + +def test_license_gate_accepts_valid_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "license_mode", "required") + monkeypatch.setattr(settings, "license_key", "x" * 32) + monkeypatch.setattr(settings, "license_public_key", None) + monkeypatch.setattr(settings, "license_revoked_token_ids", "") + monkeypatch.setattr(settings, "license_revoked_subjects", "") + + require_active_license(x_license_key="x" * 32) + + +def test_license_gate_accepts_signed_on_prem_license_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + private_key = Ed25519PrivateKey.generate() + monkeypatch.setattr(settings, "license_mode", "required") + monkeypatch.setattr(settings, "license_key", None) + monkeypatch.setattr(settings, "license_public_key", _public_key_value(private_key)) + monkeypatch.setattr(settings, "license_revoked_token_ids", "") + monkeypatch.setattr(settings, "license_revoked_subjects", "") + + token = _signed_license_token( + private_key, + { + "sub": "customer-acme", + "plan": "enterprise", + "jti": "license-2026-07", + "exp": int(time.time()) + 3600, + "seats": 25, + }, + ) + + require_active_license(x_license_key=token) + + +def test_license_gate_rejects_expired_signed_license_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + private_key = Ed25519PrivateKey.generate() + monkeypatch.setattr(settings, "license_mode", "required") + monkeypatch.setattr(settings, "license_key", None) + monkeypatch.setattr(settings, "license_public_key", _public_key_value(private_key)) + monkeypatch.setattr(settings, "license_revoked_token_ids", "") + monkeypatch.setattr(settings, "license_revoked_subjects", "") + + token = _signed_license_token( + private_key, + { + "sub": "customer-acme", + "plan": "enterprise", + "exp": int(time.time()) - 1, + }, + ) + + with pytest.raises(HTTPException) as exc_info: + require_active_license(x_license_key=token) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "license token is expired" + + +def test_license_gate_rejects_tampered_signed_license_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + private_key = Ed25519PrivateKey.generate() + monkeypatch.setattr(settings, "license_mode", "required") + monkeypatch.setattr(settings, "license_key", None) + monkeypatch.setattr(settings, "license_public_key", _public_key_value(private_key)) + monkeypatch.setattr(settings, "license_revoked_token_ids", "") + monkeypatch.setattr(settings, "license_revoked_subjects", "") + + token = _signed_license_token( + private_key, + { + "sub": "customer-acme", + "plan": "enterprise", + "exp": int(time.time()) + 3600, + }, + ) + version, _payload, signature = token.split(".") + tampered_payload = _b64url( + json.dumps( + { + "sub": "customer-acme", + "plan": "enterprise-plus", + "exp": int(time.time()) + 3600, + }, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + ) + + with pytest.raises(HTTPException) as exc_info: + require_active_license(x_license_key=f"{version}.{tampered_payload}.{signature}") + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "license token signature is invalid" + + +def test_license_gate_rejects_revoked_signed_license_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + private_key = Ed25519PrivateKey.generate() + monkeypatch.setattr(settings, "license_mode", "required") + monkeypatch.setattr(settings, "license_key", None) + monkeypatch.setattr(settings, "license_public_key", _public_key_value(private_key)) + monkeypatch.setattr(settings, "license_revoked_token_ids", "license-2026-07") + monkeypatch.setattr(settings, "license_revoked_subjects", "") + + token = _signed_license_token( + private_key, + { + "sub": "customer-acme", + "plan": "enterprise", + "jti": "license-2026-07", + "exp": int(time.time()) + 3600, + }, + ) + + with pytest.raises(HTTPException) as exc_info: + require_active_license(x_license_key=token) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "license token is revoked" + + +def test_license_gate_rejects_signed_license_with_padded_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + private_key = Ed25519PrivateKey.generate() + monkeypatch.setattr(settings, "license_mode", "required") + monkeypatch.setattr(settings, "license_key", None) + monkeypatch.setattr(settings, "license_public_key", _public_key_value(private_key)) + monkeypatch.setattr(settings, "license_revoked_token_ids", "license-2026-07") + monkeypatch.setattr(settings, "license_revoked_subjects", "") + + token = _signed_license_token( + private_key, + { + "sub": "customer-acme", + "plan": "enterprise", + "jti": " license-2026-07 ", + "exp": int(time.time()) + 3600, + }, + ) + + with pytest.raises(HTTPException) as exc_info: + require_active_license(x_license_key=token) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "license token payload is invalid" + + +def test_license_gate_rejects_revoked_license_subject( + monkeypatch: pytest.MonkeyPatch, +) -> None: + private_key = Ed25519PrivateKey.generate() + monkeypatch.setattr(settings, "license_mode", "required") + monkeypatch.setattr(settings, "license_key", None) + monkeypatch.setattr(settings, "license_public_key", _public_key_value(private_key)) + monkeypatch.setattr(settings, "license_revoked_token_ids", "") + monkeypatch.setattr(settings, "license_revoked_subjects", "customer-acme") + + token = _signed_license_token( + private_key, + { + "sub": "customer-acme", + "plan": "enterprise", + "jti": "license-2026-08", + "exp": int(time.time()) + 3600, + }, + ) + + with pytest.raises(HTTPException) as exc_info: + require_active_license(x_license_key=token) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "license token is revoked" diff --git a/backend/tests/test_license_tokens.py b/backend/tests/test_license_tokens.py new file mode 100644 index 00000000..7ae8e695 --- /dev/null +++ b/backend/tests/test_license_tokens.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import json +import subprocess +import sys +import time +from pathlib import Path + +import pytest +from fastapi import HTTPException + +from app.license_gate import require_active_license +from app.license_tokens import generate_license_key_pair, issue_license_token +from app.settings import settings + +BACKEND_ROOT = Path(__file__).resolve().parents[1] + + +def test_issued_license_token_is_accepted_by_license_gate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + key_pair = generate_license_key_pair() + monkeypatch.setattr(settings, "license_mode", "required") + monkeypatch.setattr(settings, "license_key", None) + monkeypatch.setattr(settings, "license_public_key", key_pair.public_key) + monkeypatch.setattr(settings, "license_revoked_token_ids", "") + monkeypatch.setattr(settings, "license_revoked_subjects", "") + + token = issue_license_token( + private_key=key_pair.private_key, + subject="customer-acme", + plan="enterprise", + token_id="license-2026-07", + expires_at=int(time.time()) + 3600, + seats=25, + ) + + require_active_license(x_license_key=token) + + +def test_issued_license_token_can_be_revoked_by_token_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + key_pair = generate_license_key_pair() + monkeypatch.setattr(settings, "license_mode", "required") + monkeypatch.setattr(settings, "license_key", None) + monkeypatch.setattr(settings, "license_public_key", key_pair.public_key) + monkeypatch.setattr(settings, "license_revoked_token_ids", "license-2026-07") + monkeypatch.setattr(settings, "license_revoked_subjects", "") + + token = issue_license_token( + private_key=key_pair.private_key, + subject="customer-acme", + plan="enterprise", + token_id="license-2026-07", + expires_at=int(time.time()) + 3600, + ) + + with pytest.raises(HTTPException) as exc_info: + require_active_license(x_license_key=token) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "license token is revoked" + + +def test_license_token_cli_issues_verifiable_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + key_pair = generate_license_key_pair() + completed = subprocess.run( + [ + sys.executable, + "-m", + "app.license_tokens", + "issue", + "--private-key", + key_pair.private_key, + "--sub", + "customer-acme", + "--plan", + "enterprise", + "--jti", + "license-2026-07", + "--exp", + str(int(time.time()) + 3600), + "--seats", + "25", + ], + cwd=BACKEND_ROOT, + check=True, + capture_output=True, + text=True, + ) + + monkeypatch.setattr(settings, "license_mode", "required") + monkeypatch.setattr(settings, "license_key", None) + monkeypatch.setattr(settings, "license_public_key", key_pair.public_key) + monkeypatch.setattr(settings, "license_revoked_token_ids", "") + monkeypatch.setattr(settings, "license_revoked_subjects", "") + + require_active_license(x_license_key=completed.stdout.strip()) + + +def test_license_token_cli_accepts_private_key_starting_with_dash( + monkeypatch: pytest.MonkeyPatch, +) -> None: + private_key = "-DNzy9Xc4FKHCSOB1CS4l3AkCugmRPaanmOhcBq2ylo" + public_key = "oQRzrVEGW_lxl1QbHutxr4gVh1TTnKLvdXDTGevdC8I" + completed = subprocess.run( + [ + sys.executable, + "-m", + "app.license_tokens", + "issue", + "--private-key", + private_key, + "--sub", + "customer-acme", + "--plan", + "enterprise", + "--jti", + "license-2026-07", + "--exp", + str(int(time.time()) + 3600), + ], + cwd=BACKEND_ROOT, + check=True, + capture_output=True, + text=True, + ) + + monkeypatch.setattr(settings, "license_mode", "required") + monkeypatch.setattr(settings, "license_key", None) + monkeypatch.setattr(settings, "license_public_key", public_key) + monkeypatch.setattr(settings, "license_revoked_token_ids", "") + monkeypatch.setattr(settings, "license_revoked_subjects", "") + + require_active_license(x_license_key=completed.stdout.strip()) + + +def test_license_keypair_cli_outputs_public_verifier() -> None: + completed = subprocess.run( + [ + sys.executable, + "-m", + "app.license_tokens", + "generate-keypair", + "--format", + "json", + ], + cwd=BACKEND_ROOT, + check=True, + capture_output=True, + text=True, + ) + + payload = json.loads(completed.stdout) + + assert sorted(payload) == ["private_key", "public_key"] + assert payload["private_key"] + assert payload["public_key"] diff --git a/backend/tests/test_metrics.py b/backend/tests/test_metrics.py index 745d4a8e..0601a971 100644 --- a/backend/tests/test_metrics.py +++ b/backend/tests/test_metrics.py @@ -6,6 +6,7 @@ from app.metrics import ( normalize_route_label, prime_http_metrics, + record_product_event, render_metrics, ) @@ -128,6 +129,15 @@ def test_render_metrics() -> None: mock_generate.assert_called_once() +def test_record_product_event_uses_low_cardinality_labels() -> None: + labels = {"area": "project", "action": "create", "outcome": "success"} + before = REGISTRY.get_sample_value("product_events_total", labels) or 0.0 + + record_product_event("project", "create", "success") + + assert REGISTRY.get_sample_value("product_events_total", labels) == before + 1 + + def test_normalize_route_label_none() -> None: """None routes normalize to 'unmatched'.""" assert normalize_route_label(None) == "unmatched" diff --git a/backend/tests/test_observability.py b/backend/tests/test_observability.py index 8d9ff1d6..d2d460cb 100644 --- a/backend/tests/test_observability.py +++ b/backend/tests/test_observability.py @@ -1,10 +1,11 @@ from __future__ import annotations import pytest -from fastapi import FastAPI +from fastapi import Depends, FastAPI, HTTPException from fastapi.testclient import TestClient from starlette.requests import Request +from app.license_gate import require_active_license from app.observability import _get_client_ip, _get_route_template, setup_observability from app.settings import settings @@ -68,6 +69,30 @@ class MockRoute: assert _get_route_template(req) == "unmatched" +def test_http_exception_authz_logs(caplog: pytest.LogCaptureFixture) -> None: + app = FastAPI() + setup_observability(app) + + @app.get("/api/private") + def private() -> None: + raise HTTPException(status_code=401, detail="missing bearer token") + + with caplog.at_level("WARNING"): + with TestClient(app) as client: + response = client.get("/api/private") + + assert response.status_code == 401 + assert response.json() == {"detail": "missing bearer token"} + assert any( + "authz_failure" in record.message and "status\":401" in record.message + for record in caplog.records + ) + assert any( + "\"detail\":\"missing bearer token\"" in record.message + for record in caplog.records + ) + + @pytest.mark.parametrize( ("trust_xff", "headers", "client", "expected_ip"), [ @@ -127,3 +152,92 @@ def test_get_client_ip( assert _get_client_ip(req) == expected_ip finally: settings.api_rate_limit_trust_x_forwarded_for = prev_trust + + +def test_authz_failure_metrics_are_recorded() -> None: + prev_metrics = settings.observability_metrics_enabled + prev_logging = settings.observability_request_logging_enabled + prev_token = settings.observability_metrics_token + settings.observability_metrics_enabled = True + settings.observability_request_logging_enabled = True + settings.observability_metrics_token = "test-token" + try: + app = FastAPI() + + @app.get("/api/private") + def private() -> None: + raise HTTPException(status_code=401, detail="missing bearer token") + + setup_observability(app) + with TestClient(app) as client: + _ = client.get("/api/private") + + metrics = client.get( + "/metrics", + headers={"X-Metrics-Token": "test-token"}, + ).text + + metric_lines = [ + line + for line in metrics.splitlines() + if line.startswith("authz_failures_total") + ] + assert metric_lines, "authz_failures_total metric should be present" + assert any( + 'status="401"' in line + and 'route="/api/private"' in line + and 'reason="missing_bearer_token"' in line + for line in metric_lines + ) + finally: + settings.observability_metrics_enabled = prev_metrics + settings.observability_request_logging_enabled = prev_logging + settings.observability_metrics_token = prev_token + + +def test_license_gate_sets_error_code_and_metrics() -> None: + """License enforcement failures should emit error code headers and metrics.""" + prev_mode = settings.license_mode + prev_key = settings.license_key + prev_metrics = settings.observability_metrics_enabled + prev_logging = settings.observability_request_logging_enabled + prev_token = settings.observability_metrics_token + settings.license_mode = "required" + settings.license_key = "x" * 32 + settings.observability_metrics_enabled = True + settings.observability_request_logging_enabled = True + settings.observability_metrics_token = "test-token" + + try: + app = FastAPI() + + @app.get("/api/private-license", dependencies=[Depends(require_active_license)]) + def private_license_route() -> dict[str, str]: + return {"ok": "true"} + + setup_observability(app) + client = TestClient(app) + + response = client.get("/api/private-license") + assert response.status_code == 403 + assert response.headers["X-Error-Code"] == "license_key_invalid" + assert response.headers.get("X-Request-Id") + + metrics = client.get( + "/metrics", + headers={"X-Metrics-Token": "test-token"}, + ).text + + assert any( + line.startswith("authz_failures_total") + and 'status="403"' in line + and 'route="/api/private-license"' in line + and 'reason="license_key_invalid"' in line + for line in metrics.splitlines() + ) + finally: + settings.license_mode = prev_mode + settings.license_key = prev_key + settings.observability_metrics_enabled = prev_metrics + settings.observability_request_logging_enabled = prev_logging + settings.observability_metrics_token = prev_token diff --git a/backend/tests/test_production_config.py b/backend/tests/test_production_config.py new file mode 100644 index 00000000..79ca7d93 --- /dev/null +++ b/backend/tests/test_production_config.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +from app.settings import Settings, validate_production_settings + + +def _settings(**overrides: object) -> Settings: + data: dict[str, object] = { + "database_url": "postgresql+asyncpg://user:pass@db.example.com/app", + "app_secret": "x" * 48, + "license_mode": "off", + "app_env": "production", + "oidc_issuer": "https://idp.example.com", + "oidc_audience": "pg-erd-cloud", + "cors_origins": "https://erd.example.com", + "db_introspection_allowed_hosts": "db.example.com", + } + data.update(overrides) + return Settings(**data) # type: ignore[arg-type] + + +def test_validate_production_settings_accepts_hardened_config() -> None: + assert validate_production_settings(_settings()) == [] + + +def test_validate_production_settings_is_inactive_for_development() -> None: + config = _settings( + app_env="development", + oidc_issuer=None, + oidc_audience=None, + app_secret="short", + cors_origins="http://localhost:5173", + db_introspection_allowed_hosts="", + ) + + assert validate_production_settings(config) == [] + + +def test_validate_production_settings_reports_missing_release_gates() -> None: + errors = validate_production_settings( + _settings( + oidc_issuer=None, + oidc_audience=None, + app_secret="short", + cors_origins="http://localhost:5173", + db_introspection_allowed_hosts="", + share_link_default_ttl_hours=0, + ) + ) + + assert "OIDC_ISSUER is required when APP_ENV=production" in errors + assert "OIDC_AUDIENCE is required when APP_ENV=production" in errors + assert "APP_SECRET must be at least 32 characters in production" in errors + assert "DB_INTROSPECTION_ALLOWED_HOSTS must allow explicit target DB hosts" in errors + assert "CORS_ORIGINS must include at least one public HTTPS origin" in errors + assert "SHARE_LINK_DEFAULT_TTL_HOURS must be greater than 0" in errors + + +def test_validate_production_settings_requires_llm_provider_when_shared_drafts_enabled() -> None: + errors = validate_production_settings(_settings(share_link_llm_draft_enabled=True)) + + assert ( + "LLM_API_BASE_URL, LLM_API_KEY, and LLM_MODEL are required when " + "SHARE_LINK_LLM_DRAFT_ENABLED=true" + ) in errors + + +def test_validate_production_settings_requires_llm_draft_quota_when_provider_configured() -> None: + errors = validate_production_settings( + _settings( + llm_api_base_url="https://llm.example/v1", + llm_api_key="test-key", + llm_model="test-model", + llm_draft_quota_enabled=False, + ) + ) + + assert ( + "LLM_DRAFT_QUOTA_ENABLED must stay true when live LLM provider is configured" + in errors + ) + + +def test_validate_production_settings_requires_license_verifier_when_license_required() -> None: + errors = validate_production_settings( + _settings(license_mode="required", license_key=None, license_public_key=None) + ) + + assert ( + "LICENSE_KEY or LICENSE_PUBLIC_KEY is required when LICENSE_MODE=required" + in errors + ) + + +def test_validate_production_settings_accepts_public_key_license_verifier() -> None: + errors = validate_production_settings( + _settings( + license_mode="required", + license_key=None, + license_public_key="x" * 44, + ) + ) + + assert errors == [] + + +def test_validate_production_settings_rejects_short_license_key() -> None: + errors = validate_production_settings( + _settings(license_mode="required", license_key="short-key") + ) + + assert "LICENSE_KEY must be at least 24 characters" in errors + + +def test_validate_production_settings_requires_reactivation_path_for_deactivated_subjects() -> None: + errors = validate_production_settings( + _settings(account_deactivated_subjects="customer-owner") + ) + + assert ( + "ACCOUNT_DEACTIVATED_SUBJECTS requires ACCOUNT_REACTIVATION_URL or " + "BILLING_SUPPORT_URL in production" + ) in errors + + +def test_validate_production_settings_accepts_reactivation_path_for_deactivated_subjects() -> None: + errors = validate_production_settings( + _settings( + account_deactivated_subjects="customer-owner", + account_reactivation_url="https://billing.example.com/reactivate", + ) + ) + + assert errors == [] + + +def test_validate_production_settings_rejects_insecure_billing_urls() -> None: + errors = validate_production_settings( + _settings( + billing_checkout_url="http://billing.example.com/checkout", + billing_portal_url="http://billing.example.com/portal", + billing_support_url="http://support.example.com", + account_reactivation_url="http://billing.example.com/reactivate", + ) + ) + + assert "BILLING_CHECKOUT_URL must be a public HTTPS URL in production" in errors + assert "BILLING_PORTAL_URL must be a public HTTPS URL in production" in errors + assert "BILLING_SUPPORT_URL must be a public HTTPS URL in production" in errors + assert ( + "ACCOUNT_REACTIVATION_URL must be a public HTTPS URL in production" in errors + ) + + +def test_validate_production_settings_rejects_short_billing_webhook_secrets() -> None: + errors = validate_production_settings( + _settings( + billing_webhook_secret="short", + billing_webhook_signature_secret="short", + ) + ) + + assert "BILLING_WEBHOOK_SECRET must be at least 24 characters" in errors + assert ( + "BILLING_WEBHOOK_SIGNATURE_SECRET must be at least 32 characters" in errors + ) + + +def test_validate_production_settings_requires_reactivation_path_for_contract_state_events() -> None: + errors = validate_production_settings( + _settings(billing_contract_state_events_enabled=True) + ) + + assert ( + "BILLING_CONTRACT_STATE_EVENTS_ENABLED requires " + "ACCOUNT_REACTIVATION_URL or BILLING_SUPPORT_URL in production" + ) in errors + + +def test_validate_production_settings_requires_webhook_auth_for_contract_state_events() -> None: + errors = validate_production_settings( + _settings( + billing_contract_state_events_enabled=True, + billing_support_url="https://support.example.com", + ) + ) + + assert ( + "BILLING_CONTRACT_STATE_EVENTS_ENABLED requires BILLING_WEBHOOK_SECRET " + "or BILLING_WEBHOOK_SIGNATURE_SECRET in production" + ) in errors + + +def test_validate_production_settings_accepts_signature_auth_for_contract_state_events() -> None: + errors = validate_production_settings( + _settings( + billing_contract_state_events_enabled=True, + billing_support_url="https://support.example.com", + billing_webhook_signature_secret="x" * 40, + ) + ) + + assert errors == [] + + +def test_validate_production_settings_requires_deactivation_event_types() -> None: + errors = validate_production_settings( + _settings( + billing_contract_state_events_enabled=True, + billing_contract_deactivated_event_types="", + billing_support_url="https://support.example.com", + ) + ) + + assert ( + "BILLING_CONTRACT_DEACTIVATED_EVENT_TYPES is required when " + "BILLING_CONTRACT_STATE_EVENTS_ENABLED=true" + ) in errors + + +def test_validate_production_settings_accepts_billing_event_type_aliases() -> None: + errors = validate_production_settings( + _settings( + billing_event_type_aliases=( + "stripe:customer.subscription.deleted=contract.suspended," + "customer.subscription.updated=contract.reactivated" + ) + ) + ) + + assert errors == [] + + +def test_validate_production_settings_rejects_invalid_billing_event_type_aliases() -> None: + errors = validate_production_settings( + _settings(billing_event_type_aliases="customer.subscription.deleted") + ) + + assert "BILLING_EVENT_TYPE_ALIASES entries must use source=target format" in errors + + +def test_validate_production_settings_rejects_invalid_billing_catalog_plan() -> None: + errors = validate_production_settings( + _settings(billing_allowed_plans="team, enterprise plus") + ) + + assert ( + "BILLING_ALLOWED_PLANS entries must be provider catalog plan IDs" in errors + ) + + +def test_validate_production_settings_rejects_invalid_entitlement_event_type() -> None: + errors = validate_production_settings( + _settings(billing_entitlement_event_types="invoice.paid, bad event") + ) + + assert ( + "BILLING_ENTITLEMENT_EVENT_TYPES entries must be billing event type IDs" + in errors + ) diff --git a/backend/tests/test_reversing_llm.py b/backend/tests/test_reversing_llm.py index fd662a35..46e1a400 100644 --- a/backend/tests/test_reversing_llm.py +++ b/backend/tests/test_reversing_llm.py @@ -1,19 +1,24 @@ from __future__ import annotations +import datetime as dt import json +import logging import uuid from types import SimpleNamespace import httpx import pytest from fastapi import HTTPException +from prometheus_client import REGISTRY +from app import llm_quota from app.api import share, snapshots from app.auth import CurrentUser -from app.models import SchemaSnapshot, SchemaSnapshotData, ShareLink +from app.models import LlmDraftUsageEvent, SchemaSnapshot, SchemaSnapshotData, ShareLink from app.settings import settings from app.spec.llm import ( LlmConfigurationError, + LlmPromptTooLargeError, LlmProviderError, generate_reversing_llm_draft, ) @@ -42,6 +47,55 @@ def _snapshot() -> dict: } +def _request() -> SimpleNamespace: + return SimpleNamespace( + headers={"user-agent": "pytest"}, + client=SimpleNamespace(host="127.0.0.1"), + state=SimpleNamespace(request_id="test-request-id"), + ) + + +def _share_audit_events(caplog: pytest.LogCaptureFixture) -> list[dict[str, object]]: + events: list[dict[str, object]] = [] + for record in caplog.records: + if record.name != "app.share": + continue + try: + payload = json.loads(record.getMessage()) + except json.JSONDecodeError: + continue + if payload.get("event") == "share_audit": + events.append(payload) + return events + + +def _llm_usage_events(caplog: pytest.LogCaptureFixture) -> list[dict[str, object]]: + events: list[dict[str, object]] = [] + for record in caplog.records: + if record.name != "app.llm_usage": + continue + try: + payload = json.loads(record.getMessage()) + except json.JSONDecodeError: + continue + if payload.get("event") == "llm_draft_usage": + events.append(payload) + return events + + +def _llm_draft_metric_value( + *, + surface: str, + artifact: str, + outcome: str, +) -> float: + value = REGISTRY.get_sample_value( + "llm_draft_requests_total", + {"surface": surface, "artifact": artifact, "outcome": outcome}, + ) + return float(value or 0.0) + + @pytest.mark.asyncio async def test_generate_reversing_llm_draft_posts_chat_completion( monkeypatch: pytest.MonkeyPatch, @@ -57,6 +111,7 @@ def handler(request: httpx.Request) -> httpx.Response: body = json.loads(request.content) seen["model"] = body["model"] seen["messages"] = body["messages"] + seen["max_tokens"] = body["max_tokens"] return httpx.Response( 200, json={ @@ -74,6 +129,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen["url"] == "https://llm.example/v1/chat/completions" assert seen["authorization"] == "Bearer test-key" assert seen["model"] == "test-model" + assert seen["max_tokens"] == settings.llm_max_output_tokens messages = seen["messages"] assert isinstance(messages, list) assert messages[0]["role"] == "system" @@ -93,6 +149,30 @@ async def test_generate_reversing_llm_draft_requires_configuration( await generate_reversing_llm_draft(_snapshot()) +@pytest.mark.asyncio +async def test_generate_reversing_llm_draft_rejects_oversized_prompt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "llm_api_base_url", "https://llm.example/v1") + monkeypatch.setattr(settings, "llm_api_key", "test-key") + monkeypatch.setattr(settings, "llm_model", "test-model") + monkeypatch.setattr(settings, "llm_max_prompt_chars", 1_000) + + snapshot = _snapshot() + snapshot["relations"] = [ + { + "schema_name": "public", + "relation_name": f"large_table_{i}", + "relation_oid": i, + "relation_kind": "r", + } + for i in range(300) + ] + + with pytest.raises(LlmPromptTooLargeError): + await generate_reversing_llm_draft(snapshot) + + @pytest.mark.asyncio async def test_generate_reversing_llm_draft_rejects_bad_provider_response( monkeypatch: pytest.MonkeyPatch, @@ -126,14 +206,26 @@ async def test_generate_reversing_llm_draft_rejects_invalid_json( class _SnapshotSession: + def __init__(self) -> None: + self.added: list[object] = [] + self.committed = 0 + async def get(self, model: object, _: uuid.UUID) -> object: assert model is SchemaSnapshotData return SimpleNamespace(snapshot_json=_snapshot()) + def add(self, value: object) -> None: + self.added.append(value) + + async def commit(self) -> None: + self.committed += 1 + class _ShareSession: def __init__(self) -> None: self.project_space_uuid = uuid.uuid4() + self.added: list[object] = [] + self.committed = 0 async def get(self, model: object, _: uuid.UUID) -> object: if model is ShareLink: @@ -146,6 +238,60 @@ async def get(self, model: object, _: uuid.UUID) -> object: assert model is SchemaSnapshotData return SimpleNamespace(snapshot_json=_snapshot()) + def add(self, value: object) -> None: + self.added.append(value) + + async def commit(self) -> None: + self.committed += 1 + + +class _ShareLinkScalars: + def __init__(self, data: list[object]) -> None: + self.data = data + + def all(self) -> list[object]: + return self.data + + +class _ShareLinkResult: + def __init__(self, data: list[object]) -> None: + self.data = data + + def scalar_one_or_none(self) -> object: + return self.data[0] if self.data else None + + def scalars(self) -> _ShareLinkScalars: + return _ShareLinkScalars(self.data) + + +class _ShareLinkManagementSession: + def __init__(self, role: str | None = "owner", links: list[ShareLink] | None = None): + self.role = role + self.links = links or [] + self.added: list[ShareLink] = [] + self.deleted: list[ShareLink] = [] + self.execute_count = 0 + self.committed = False + + async def execute(self, _: object) -> _ShareLinkResult: + self.execute_count += 1 + if self.execute_count == 1: + return _ShareLinkResult([self.role] if self.role else []) + return _ShareLinkResult(self.links) + + async def get(self, model: object, _: uuid.UUID) -> object: + assert model is ShareLink + return self.links[0] if self.links else None + + def add(self, link: ShareLink) -> None: + self.added.append(link) + + async def delete(self, link: ShareLink) -> None: + self.deleted.append(link) + + async def commit(self) -> None: + self.committed = True + async def _authorized_snapshot(*_: object) -> object: return SimpleNamespace() @@ -155,6 +301,10 @@ async def _raise_config_error(_: object) -> str: raise LlmConfigurationError("LLM_API_BASE_URL is required for live LLM drafts") +async def _raise_prompt_too_large(_: object) -> str: + raise LlmPromptTooLargeError("prompt too large") + + def _current_user() -> CurrentUser: return CurrentUser( user_account_uuid=uuid.uuid4(), @@ -163,6 +313,88 @@ def _current_user() -> CurrentUser: ) +@pytest.mark.asyncio +async def test_create_share_link_uses_default_expiration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "share_link_default_ttl_hours", 24) + session = _ShareLinkManagementSession() + before = dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=24) + + out = await share.create_share_link( + uuid.uuid4(), + body=None, + request=_request(), + user=_current_user(), + session=session, + ) + + after = dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=24) + assert len(session.added) == 1 + assert out.share_link_uuid == session.added[0].share_link_uuid + assert out.url_path == f"/api/share/{out.share_link_uuid}" + assert out.expires_at is not None + assert before <= out.expires_at <= after + assert session.committed + + +@pytest.mark.asyncio +async def test_create_share_link_can_explicitly_disable_expiration() -> None: + session = _ShareLinkManagementSession() + + out = await share.create_share_link( + uuid.uuid4(), + body=share.ShareLinkCreateIn(expires_in_hours=0), + request=_request(), + user=_current_user(), + session=session, + ) + + assert out.expires_at is None + + +@pytest.mark.asyncio +async def test_list_share_links_requires_owner() -> None: + session = _ShareLinkManagementSession(role="viewer") + + with pytest.raises(HTTPException) as exc_info: + await share.list_share_links( + uuid.uuid4(), + request=_request(), + user=_current_user(), + session=session, + ) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "owner role required" + + +@pytest.mark.asyncio +async def test_delete_share_link_revokes_existing_link() -> None: + project_uuid = uuid.uuid4() + link = ShareLink( + share_link_uuid=uuid.uuid4(), + project_space_uuid=project_uuid, + created_by_user_uuid=uuid.uuid4(), + permission_kind="viewer", + expires_at=dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=7), + created_at=dt.datetime.now(dt.timezone.utc), + ) + session = _ShareLinkManagementSession(links=[link]) + + response = await share.delete_share_link( + project_uuid, + link.share_link_uuid, + request=_request(), + user=_current_user(), + session=session, + ) + + assert response.status_code == 204 + assert session.deleted == [link] + assert session.committed + + def _assert_sanitized_config_error( exc_info: pytest.ExceptionInfo[HTTPException], ) -> None: @@ -171,6 +403,20 @@ def _assert_sanitized_config_error( assert "LLM_API_BASE_URL" not in exc_info.value.detail +def _assert_prompt_too_large_error( + exc_info: pytest.ExceptionInfo[HTTPException], +) -> None: + assert exc_info.value.status_code == 413 + assert exc_info.value.detail == "LLM prompt too large" + + +def _assert_share_link_llm_draft_disabled( + exc_info: pytest.ExceptionInfo[HTTPException], +) -> None: + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "share link LLM draft is disabled" + + @pytest.mark.asyncio async def test_snapshot_reversing_draft_hides_llm_configuration_detail( monkeypatch: pytest.MonkeyPatch, @@ -189,6 +435,149 @@ async def test_snapshot_reversing_draft_hides_llm_configuration_detail( _assert_sanitized_config_error(exc_info) +@pytest.mark.asyncio +async def test_snapshot_reversing_draft_records_usage_metric_and_audit_log( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + project_uuid = uuid.uuid4() + snapshot_uuid = uuid.uuid4() + user = _current_user() + + async def _authorized_snapshot_with_project(*_: object) -> object: + return SimpleNamespace(project_space_uuid=project_uuid) + + async def _draft(_: object) -> str: + return "# DB Reversing Specification\n\nDraft" + + monkeypatch.setattr( + snapshots, "_get_authorized_snapshot", _authorized_snapshot_with_project + ) + monkeypatch.setattr(snapshots, "generate_reversing_llm_draft", _draft) + before = _llm_draft_metric_value( + surface="authenticated", + artifact="reversing_spec", + outcome="success", + ) + session = _SnapshotSession() + + with caplog.at_level(logging.INFO, logger="app.llm_usage"): + result = await snapshots.export_snapshot_reversing_spec( + snapshot_uuid, + mode="llm-draft", + user=user, + session=session, + ) + + assert result == "# DB Reversing Specification\n\nDraft" + assert session.committed == 1 + assert len(session.added) == 1 + assert isinstance(session.added[0], LlmDraftUsageEvent) + usage_event = session.added[0] + assert usage_event.subject == user.subject + assert usage_event.user_account_uuid == user.user_account_uuid + assert usage_event.project_space_uuid == project_uuid + assert usage_event.schema_snapshot_uuid == snapshot_uuid + assert usage_event.outcome == "success" + assert usage_event.output_chars == len(result) + assert ( + _llm_draft_metric_value( + surface="authenticated", + artifact="reversing_spec", + outcome="success", + ) + == before + 1 + ) + events = _llm_usage_events(caplog) + assert events + assert events[-1]["surface"] == "authenticated" + assert events[-1]["artifact"] == "reversing_spec" + assert events[-1]["outcome"] == "success" + assert events[-1]["user_account_uuid"] == str(user.user_account_uuid) + assert events[-1]["project_space_uuid"] == str(project_uuid) + assert events[-1]["schema_snapshot_uuid"] == str(snapshot_uuid) + assert isinstance(events[-1]["input_chars"], int) + assert events[-1]["input_chars"] > 0 + assert events[-1]["output_chars"] == len(result) + + +@pytest.mark.asyncio +async def test_snapshot_reversing_draft_enforces_account_llm_quota( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + project_uuid = uuid.uuid4() + snapshot_uuid = uuid.uuid4() + user = _current_user() + draft_calls = 0 + + async def _authorized_snapshot_with_project(*_: object) -> object: + return SimpleNamespace(project_space_uuid=project_uuid) + + async def _draft(_: object) -> str: + nonlocal draft_calls + draft_calls += 1 + return "# DB Reversing Specification\n\nDraft" + + llm_quota.reset_llm_draft_quota_state() + monkeypatch.setattr(settings, "llm_draft_quota_enabled", True) + monkeypatch.setattr(settings, "llm_draft_quota_requests", 1) + monkeypatch.setattr(settings, "llm_draft_quota_window_seconds", 60.0) + monkeypatch.setattr( + snapshots, "_get_authorized_snapshot", _authorized_snapshot_with_project + ) + monkeypatch.setattr(snapshots, "generate_reversing_llm_draft", _draft) + + first = await snapshots.export_snapshot_reversing_spec( + snapshot_uuid, + mode="llm-draft", + user=user, + session=_SnapshotSession(), + ) + assert first == "# DB Reversing Specification\n\nDraft" + + with caplog.at_level(logging.INFO, logger="app.llm_usage"): + with pytest.raises(HTTPException) as exc_info: + await snapshots.export_snapshot_reversing_spec( + snapshot_uuid, + mode="llm-draft", + user=user, + session=_SnapshotSession(), + ) + + assert exc_info.value.status_code == 429 + assert exc_info.value.detail == "LLM draft quota exceeded" + assert draft_calls == 1 + events = _llm_usage_events(caplog) + assert events + assert events[-1]["surface"] == "authenticated" + assert events[-1]["artifact"] == "reversing_spec" + assert events[-1]["outcome"] == "quota_exceeded" + assert events[-1]["user_account_uuid"] == str(user.user_account_uuid) + assert events[-1]["project_space_uuid"] == str(project_uuid) + assert events[-1]["schema_snapshot_uuid"] == str(snapshot_uuid) + + +@pytest.mark.asyncio +async def test_snapshot_reversing_draft_rejects_oversized_prompt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(snapshots, "_get_authorized_snapshot", _authorized_snapshot) + monkeypatch.setattr( + snapshots, "generate_reversing_llm_draft", _raise_prompt_too_large + ) + + with pytest.raises(HTTPException) as exc_info: + await snapshots.export_snapshot_reversing_spec( + uuid.uuid4(), + mode="llm-draft", + user=_current_user(), + session=_SnapshotSession(), + ) + + _assert_prompt_too_large_error(exc_info) + + @pytest.mark.asyncio async def test_snapshot_index_design_draft_hides_llm_configuration_detail( monkeypatch: pytest.MonkeyPatch, @@ -211,10 +600,189 @@ async def test_snapshot_index_design_draft_hides_llm_configuration_detail( _assert_sanitized_config_error(exc_info) +@pytest.mark.asyncio +async def test_shared_reversing_draft_is_disabled_by_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "share_link_llm_draft_enabled", False) + + async def _unexpected_llm_call(_: object) -> str: + pytest.fail("public share link should not call the LLM provider by default") + + monkeypatch.setattr(share, "generate_reversing_llm_draft", _unexpected_llm_call) + + with pytest.raises(HTTPException) as exc_info: + await share.export_shared_snapshot_reversing_spec( + uuid.uuid4(), + uuid.uuid4(), + mode="llm-draft", + request=_request(), + session=_ShareSession(), + ) + + _assert_share_link_llm_draft_disabled(exc_info) + + +@pytest.mark.asyncio +async def test_shared_reversing_draft_disabled_event_is_audited( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setattr(settings, "share_link_llm_draft_enabled", False) + + with caplog.at_level(logging.INFO, logger="app.share"): + with pytest.raises(HTTPException) as exc_info: + await share.export_shared_snapshot_reversing_spec( + uuid.uuid4(), + uuid.uuid4(), + mode="llm-draft", + request=_request(), + session=_ShareSession(), + ) + _assert_share_link_llm_draft_disabled(exc_info) + + events = _share_audit_events(caplog) + assert events + assert events[-1]["request_id"] == "test-request-id" + assert events[-1]["action"] == "share_snapshot.reversing_spec" + assert events[-1]["outcome"] == "denied" + assert events[-1]["mode"] == "llm-draft" + assert events[-1]["error_detail"] == "share link LLM draft is disabled" + + +@pytest.mark.asyncio +async def test_shared_reversing_draft_disabled_records_usage_metric( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "share_link_llm_draft_enabled", False) + session = _ShareSession() + before = _llm_draft_metric_value( + surface="share_link", + artifact="reversing_spec", + outcome="disabled", + ) + + with pytest.raises(HTTPException): + await share.export_shared_snapshot_reversing_spec( + uuid.uuid4(), + uuid.uuid4(), + mode="llm-draft", + request=_request(), + session=session, + ) + + assert session.committed == 1 + assert len(session.added) == 1 + assert isinstance(session.added[0], LlmDraftUsageEvent) + assert session.added[0].outcome == "disabled" + assert ( + _llm_draft_metric_value( + surface="share_link", + artifact="reversing_spec", + outcome="disabled", + ) + == before + 1 + ) + + +@pytest.mark.asyncio +async def test_shared_reversing_draft_success_event_is_audited( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setattr(settings, "share_link_llm_draft_enabled", True) + + async def _draft(_: object) -> str: + return "# DB Reversing Specification\n\nDraft" + + monkeypatch.setattr(share, "generate_reversing_llm_draft", _draft) + session = _ShareSession() + share_link_uuid = uuid.uuid4() + schema_snapshot_uuid = uuid.uuid4() + + with caplog.at_level(logging.INFO, logger="app.share"): + result = await share.export_shared_snapshot_reversing_spec( + share_link_uuid, + schema_snapshot_uuid, + mode="llm-draft", + request=_request(), + session=session, + ) + + assert result == "# DB Reversing Specification\n\nDraft" + events = _share_audit_events(caplog) + assert events + assert events[-1]["request_id"] == "test-request-id" + assert events[-1]["action"] == "share_snapshot.reversing_spec" + assert events[-1]["outcome"] == "success" + assert events[-1]["mode"] == "llm-draft" + assert events[-1]["share_link_uuid"] == str(share_link_uuid) + assert events[-1]["schema_snapshot_uuid"] == str(schema_snapshot_uuid) + assert events[-1]["project_space_uuid"] == str(session.project_space_uuid) + + +@pytest.mark.asyncio +async def test_shared_reversing_draft_enforces_share_link_llm_quota( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setattr(settings, "share_link_llm_draft_enabled", True) + monkeypatch.setattr(settings, "llm_draft_quota_enabled", True) + monkeypatch.setattr(settings, "llm_draft_quota_requests", 1) + monkeypatch.setattr(settings, "llm_draft_quota_window_seconds", 60.0) + llm_quota.reset_llm_draft_quota_state() + draft_calls = 0 + + async def _draft(_: object) -> str: + nonlocal draft_calls + draft_calls += 1 + return "# DB Reversing Specification\n\nDraft" + + monkeypatch.setattr(share, "generate_reversing_llm_draft", _draft) + session = _ShareSession() + share_link_uuid = uuid.uuid4() + schema_snapshot_uuid = uuid.uuid4() + + first = await share.export_shared_snapshot_reversing_spec( + share_link_uuid, + schema_snapshot_uuid, + mode="llm-draft", + request=_request(), + session=session, + ) + assert first == "# DB Reversing Specification\n\nDraft" + + with caplog.at_level(logging.INFO): + with pytest.raises(HTTPException) as exc_info: + await share.export_shared_snapshot_reversing_spec( + share_link_uuid, + schema_snapshot_uuid, + mode="llm-draft", + request=_request(), + session=session, + ) + + assert exc_info.value.status_code == 429 + assert exc_info.value.detail == "LLM draft quota exceeded" + assert draft_calls == 1 + usage_events = _llm_usage_events(caplog) + assert usage_events + assert usage_events[-1]["surface"] == "share_link" + assert usage_events[-1]["artifact"] == "reversing_spec" + assert usage_events[-1]["outcome"] == "quota_exceeded" + assert usage_events[-1]["share_link_uuid"] == str(share_link_uuid) + share_events = _share_audit_events(caplog) + assert share_events + assert share_events[-1]["action"] == "share_snapshot.reversing_spec" + assert share_events[-1]["outcome"] == "denied" + assert share_events[-1]["error_detail"] == "LLM draft quota exceeded" + + @pytest.mark.asyncio async def test_shared_reversing_draft_hides_llm_configuration_detail( monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setattr(settings, "share_link_llm_draft_enabled", True) monkeypatch.setattr(share, "generate_reversing_llm_draft", _raise_config_error) with pytest.raises(HTTPException) as exc_info: @@ -222,16 +790,105 @@ async def test_shared_reversing_draft_hides_llm_configuration_detail( uuid.uuid4(), uuid.uuid4(), mode="llm-draft", + request=_request(), session=_ShareSession(), ) _assert_sanitized_config_error(exc_info) +@pytest.mark.asyncio +async def test_shared_reversing_draft_configuration_error_event_is_audited( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setattr(settings, "share_link_llm_draft_enabled", True) + monkeypatch.setattr(share, "generate_reversing_llm_draft", _raise_config_error) + + with caplog.at_level(logging.INFO, logger="app.share"): + with pytest.raises(HTTPException) as exc_info: + await share.export_shared_snapshot_reversing_spec( + uuid.uuid4(), + uuid.uuid4(), + mode="llm-draft", + request=_request(), + session=_ShareSession(), + ) + + _assert_sanitized_config_error(exc_info) + events = _share_audit_events(caplog) + assert events + assert events[-1]["request_id"] == "test-request-id" + assert events[-1]["action"] == "share_snapshot.reversing_spec" + assert events[-1]["outcome"] == "failed" + assert events[-1]["mode"] == "llm-draft" + assert events[-1]["error_detail"] == "LLM configuration error" + + +@pytest.mark.asyncio +async def test_shared_index_design_draft_is_disabled_by_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "share_link_llm_draft_enabled", False) + + async def _unexpected_llm_call(_: object) -> str: + pytest.fail("public share link should not call the LLM provider by default") + + monkeypatch.setattr(share, "generate_index_design_llm_draft", _unexpected_llm_call) + + with pytest.raises(HTTPException) as exc_info: + await share.export_shared_snapshot_index_design( + uuid.uuid4(), + uuid.uuid4(), + mode="llm-draft", + request=_request(), + session=_ShareSession(), + ) + + _assert_share_link_llm_draft_disabled(exc_info) + + +@pytest.mark.asyncio +async def test_shared_index_design_draft_success_event_is_audited( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setattr(settings, "share_link_llm_draft_enabled", True) + + async def _draft(_: object) -> str: + return "# ERD Index Design\n\nDraft" + + monkeypatch.setattr(share, "generate_index_design_llm_draft", _draft) + session = _ShareSession() + share_link_uuid = uuid.uuid4() + schema_snapshot_uuid = uuid.uuid4() + + with caplog.at_level(logging.INFO, logger="app.share"): + result = await share.export_shared_snapshot_index_design( + share_link_uuid, + schema_snapshot_uuid, + mode="llm-draft", + request=_request(), + session=session, + ) + + assert result == "# ERD Index Design\n\nDraft" + events = _share_audit_events(caplog) + assert events + assert events[-1]["request_id"] == "test-request-id" + assert events[-1]["action"] == "share_snapshot.index_design" + assert events[-1]["outcome"] == "success" + assert events[-1]["mode"] == "llm-draft" + assert events[-1]["share_link_uuid"] == str(share_link_uuid) + assert events[-1]["schema_snapshot_uuid"] == str(schema_snapshot_uuid) + assert events[-1]["project_space_uuid"] == str(session.project_space_uuid) + + @pytest.mark.asyncio async def test_shared_index_design_draft_hides_llm_configuration_detail( monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setattr(settings, "share_link_llm_draft_enabled", True) monkeypatch.setattr(share, "generate_index_design_llm_draft", _raise_config_error) with pytest.raises(HTTPException) as exc_info: @@ -239,7 +896,132 @@ async def test_shared_index_design_draft_hides_llm_configuration_detail( uuid.uuid4(), uuid.uuid4(), mode="llm-draft", + request=_request(), session=_ShareSession(), ) _assert_sanitized_config_error(exc_info) + + +@pytest.mark.asyncio +async def test_shared_index_design_draft_disabled_event_is_audited( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setattr(settings, "share_link_llm_draft_enabled", False) + + with caplog.at_level(logging.INFO, logger="app.share"): + with pytest.raises(HTTPException) as exc_info: + await share.export_shared_snapshot_index_design( + uuid.uuid4(), + uuid.uuid4(), + mode="llm-draft", + request=_request(), + session=_ShareSession(), + ) + + _assert_share_link_llm_draft_disabled(exc_info) + events = _share_audit_events(caplog) + assert events + assert events[-1]["request_id"] == "test-request-id" + assert events[-1]["action"] == "share_snapshot.index_design" + assert events[-1]["outcome"] == "denied" + assert events[-1]["mode"] == "llm-draft" + assert events[-1]["error_detail"] == "share link LLM draft is disabled" + + +@pytest.mark.asyncio +async def test_shared_index_design_draft_configuration_error_event_is_audited( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setattr(settings, "share_link_llm_draft_enabled", True) + monkeypatch.setattr(share, "generate_index_design_llm_draft", _raise_config_error) + + with caplog.at_level(logging.INFO, logger="app.share"): + with pytest.raises(HTTPException) as exc_info: + await share.export_shared_snapshot_index_design( + uuid.uuid4(), + uuid.uuid4(), + mode="llm-draft", + request=_request(), + session=_ShareSession(), + ) + + _assert_sanitized_config_error(exc_info) + events = _share_audit_events(caplog) + assert events + assert events[-1]["request_id"] == "test-request-id" + assert events[-1]["action"] == "share_snapshot.index_design" + assert events[-1]["outcome"] == "failed" + assert events[-1]["mode"] == "llm-draft" + assert events[-1]["error_detail"] == "LLM configuration error" + + +@pytest.mark.asyncio +async def test_share_link_create_audit_event_is_emitted( + caplog: pytest.LogCaptureFixture, +) -> None: + session = _ShareLinkManagementSession() + project_uuid = uuid.uuid4() + + with caplog.at_level(logging.INFO, logger="app.share"): + out = await share.create_share_link( + project_uuid, + body=None, + request=_request(), + user=_current_user(), + session=session, + ) + + events = _share_audit_events(caplog) + assert events + assert events[-1]["action"] == "share_link.create" + assert events[-1]["outcome"] == "success" + assert events[-1]["project_space_uuid"] == str(project_uuid) + assert events[-1]["share_link_uuid"] == str(out.share_link_uuid) + + +@pytest.mark.asyncio +async def test_share_link_list_denied_audit_event_is_emitted( + caplog: pytest.LogCaptureFixture, +) -> None: + session = _ShareLinkManagementSession(role="viewer") + + with caplog.at_level(logging.INFO, logger="app.share"): + with pytest.raises(HTTPException): + await share.list_share_links( + uuid.uuid4(), + request=_request(), + user=_current_user(), + session=session, + ) + + events = _share_audit_events(caplog) + assert events + assert events[-1]["action"] == "share_link.list" + assert events[-1]["outcome"] == "denied" + assert events[-1]["error_detail"] == "owner role required" + + +@pytest.mark.asyncio +async def test_share_snapshot_reversing_spec_audit_event_is_emitted( + caplog: pytest.LogCaptureFixture, +) -> None: + session = _ShareSession() + + with caplog.at_level(logging.INFO, logger="app.share"): + await share.export_shared_snapshot_reversing_spec( + uuid.uuid4(), + uuid.uuid4(), + mode="markdown", + request=_request(), + session=session, + ) + + events = _share_audit_events(caplog) + assert events + assert events[-1]["request_id"] == "test-request-id" + assert events[-1]["action"] == "share_snapshot.reversing_spec" + assert events[-1]["outcome"] == "success" + assert events[-1]["mode"] == "markdown" diff --git a/backend/tests/test_usage_quotas.py b/backend/tests/test_usage_quotas.py new file mode 100644 index 00000000..e2702121 --- /dev/null +++ b/backend/tests/test_usage_quotas.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import uuid +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from app import usage_quotas +from app.settings import settings + + +class FakeResult: + def __init__(self, value: int) -> None: + self.value = value + + def scalar_one(self) -> int: + return self.value + + +class FakeSession: + def __init__(self, value: int | list[int]) -> None: + self.values = value if isinstance(value, list) else [value] + self.execute_count = 0 + + async def execute(self, stmt: object) -> FakeResult: + del stmt + value = self.values[min(self.execute_count, len(self.values) - 1)] + self.execute_count += 1 + return FakeResult(value) + + +@pytest.mark.asyncio +async def test_project_quota_is_skipped_when_unlimited( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = FakeSession(999) + monkeypatch.setattr(settings, "billing_max_projects_per_user", 0) + + await usage_quotas.enforce_project_quota(session, uuid.uuid4()) + + assert session.execute_count == 0 + + +@pytest.mark.asyncio +async def test_project_quota_rejects_when_limit_reached( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = FakeSession(2) + monkeypatch.setattr(settings, "billing_max_projects_per_user", 2) + + with pytest.raises(HTTPException) as exc_info: + await usage_quotas.enforce_project_quota(session, uuid.uuid4()) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "project quota exceeded" + + +@pytest.mark.asyncio +async def test_connection_quota_rejects_when_limit_reached( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = FakeSession(3) + monkeypatch.setattr(settings, "billing_max_connections_per_project", 3) + + with pytest.raises(HTTPException) as exc_info: + await usage_quotas.enforce_connection_quota(session, uuid.uuid4()) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "connection quota exceeded" + + +@pytest.mark.asyncio +async def test_snapshot_quota_rejects_when_limit_reached( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = FakeSession(4) + monkeypatch.setattr(settings, "billing_max_snapshots_per_project", 4) + + with pytest.raises(HTTPException) as exc_info: + await usage_quotas.enforce_snapshot_quota(session, uuid.uuid4()) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "snapshot quota exceeded" + + +@pytest.mark.asyncio +async def test_share_link_quota_rejects_when_limit_reached( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = FakeSession(5) + monkeypatch.setattr(settings, "billing_max_share_links_per_project", 5) + + with pytest.raises(HTTPException) as exc_info: + await usage_quotas.enforce_share_link_quota(session, uuid.uuid4()) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "share link quota exceeded" + + +@pytest.mark.asyncio +async def test_seat_quota_is_skipped_when_no_contract_seat_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = FakeSession([999, 0]) + + async def fake_latest_entitlement(session: object, subject: str) -> object: + del session, subject + return SimpleNamespace(seat_count=None) + + monkeypatch.setattr( + usage_quotas, + "latest_billing_entitlement_for_subject", + fake_latest_entitlement, + ) + + await usage_quotas.enforce_seat_quota( + session, + owner_user_account_uuid=uuid.uuid4(), + owner_subject="customer-owner", + candidate_user_account_uuid=uuid.uuid4(), + ) + + assert session.execute_count == 0 + + +@pytest.mark.asyncio +async def test_seat_quota_rejects_new_member_when_contract_limit_reached( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = FakeSession([2, 0]) + + async def fake_latest_entitlement(session: object, subject: str) -> object: + del session, subject + return SimpleNamespace(seat_count=2) + + monkeypatch.setattr( + usage_quotas, + "latest_billing_entitlement_for_subject", + fake_latest_entitlement, + ) + + with pytest.raises(HTTPException) as exc_info: + await usage_quotas.enforce_seat_quota( + session, + owner_user_account_uuid=uuid.uuid4(), + owner_subject="customer-owner", + candidate_user_account_uuid=uuid.uuid4(), + ) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "seat quota exceeded" + assert session.execute_count == 2 + + +@pytest.mark.asyncio +async def test_seat_quota_allows_existing_member_role_update_at_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = FakeSession([2, 1]) + + async def fake_latest_entitlement(session: object, subject: str) -> object: + del session, subject + return SimpleNamespace(seat_count=2) + + monkeypatch.setattr( + usage_quotas, + "latest_billing_entitlement_for_subject", + fake_latest_entitlement, + ) + + await usage_quotas.enforce_seat_quota( + session, + owner_user_account_uuid=uuid.uuid4(), + owner_subject="customer-owner", + candidate_user_account_uuid=uuid.uuid4(), + ) + + assert session.execute_count == 2 diff --git a/compose.prod.yaml b/compose.prod.yaml index 40e584bf..4550a34c 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -45,7 +45,8 @@ services: env_file: ./.env environment: DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-erd}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-erd} - CORS_ORIGINS: http://localhost:8080 + APP_ENV: production + CORS_ORIGINS: ${CORS_ORIGINS:?set CORS_ORIGINS in .env for production} APP_SECRET_FILE: /run/secrets/app_secret secrets: - app_secret diff --git a/deploy/prometheus/pg-erd-cloud-alerts.yml b/deploy/prometheus/pg-erd-cloud-alerts.yml new file mode 100644 index 00000000..4c6c4c82 --- /dev/null +++ b/deploy/prometheus/pg-erd-cloud-alerts.yml @@ -0,0 +1,102 @@ +groups: + - name: pg-erd-cloud-commercial-readiness + rules: + - alert: PgErdCloudHigh5xxRate + expr: | + ( + sum(rate(http_requests_total{status=~"5.."}[5m])) + / + clamp_min(sum(rate(http_requests_total[5m])), 1) + ) > 0.01 + for: 5m + labels: + severity: page + area: reliability + annotations: + summary: "pg-erd-cloud 5xx rate is above 1%" + description: "Backend 5xx responses are above the commercial readiness threshold for 5 minutes." + + - alert: PgErdCloudHighRouteLatency + expr: | + histogram_quantile( + 0.95, + sum by (le, route) (rate(http_request_duration_seconds_bucket[10m])) + ) > 1 + for: 10m + labels: + severity: ticket + area: reliability + annotations: + summary: "pg-erd-cloud p95 route latency is above 1s" + description: "A route has exceeded the p95 latency threshold for 10 minutes." + + - alert: PgErdCloudAuthzFailureSpike + expr: | + sum by (route, reason) (rate(authz_failures_total[5m])) > 10 + for: 5m + labels: + severity: ticket + area: security + annotations: + summary: "pg-erd-cloud authz failures are spiking" + description: "Authentication or authorization failures exceeded 10/s for a route/reason pair." + + - alert: PgErdCloudShareAbuseOrFailureSpike + expr: | + sum by (action, outcome) ( + rate(share_audit_events_total{outcome=~"denied|failed"}[5m]) + ) > 5 + for: 5m + labels: + severity: ticket + area: abuse-control + annotations: + summary: "pg-erd-cloud share audit denied/failed events are spiking" + description: "Public share-link denied or failed events exceeded 5/s for an action/outcome pair." + + - alert: PgErdCloudBillingWebhookFailures + expr: | + increase(billing_events_total{outcome=~"rejected_auth|rejected_config|rejected_catalog"}[10m]) > 0 + for: 0m + labels: + severity: ticket + area: billing + annotations: + summary: "pg-erd-cloud billing webhook reconciliation is failing" + description: "Billing webhook authentication, configuration, or catalog validation failures occurred in the last 10 minutes." + + - alert: PgErdCloudLlmDraftFailures + expr: | + increase(llm_draft_requests_total{outcome=~"configuration_error|provider_error|prompt_too_large|quota_exceeded"}[10m]) > 0 + for: 0m + labels: + severity: ticket + area: llm-cost-control + annotations: + summary: "pg-erd-cloud live LLM draft requests are failing" + description: "Live LLM draft configuration, prompt-size, provider, or quota failures occurred in the last 10 minutes." + + - alert: PgErdCloudJobFailures + expr: | + increase(job_queue_jobs_total{outcome="failed"}[10m]) > 0 + for: 0m + labels: + severity: ticket + area: jobs + annotations: + summary: "pg-erd-cloud background jobs are failing" + description: "One or more background jobs failed in the last 10 minutes." + + - alert: PgErdCloudJobQueueWaitHigh + expr: | + histogram_quantile( + 0.95, + sum by (le, job_type) (rate(job_queue_wait_seconds_bucket[10m])) + ) > 60 + for: 10m + labels: + severity: ticket + area: jobs + annotations: + summary: "pg-erd-cloud job queue wait time is high" + description: "A job type exceeded the p95 queue wait threshold for 10 minutes." diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 00000000..c6276912 --- /dev/null +++ b/design-qa.md @@ -0,0 +1,37 @@ +**Findings** +- No actionable P0/P1/P2 mismatches remain. + Location: `ExportModal` share/export component. + Evidence: Figma source node `29:143` and the rendered implementation both use a centered 640px modal, two-column desktop body, compact bordered sections, primary blue actions, neutral/success/error status strip treatment, and footer actions aligned to the right. + Impact: The implementation preserves the intended share/export hierarchy and does not block handoff. + Fix: None required. + +**Open Questions** +- The Figma source has three export artifact rows: SQL DDL, SVG image, and Mermaid. The implementation adds PlantUML because the current product already exposes PlantUML export. This is treated as an intentional product-capability extension. +- The implementation is localized in Korean while the Figma source is in English. The copy preserves the same intent and hierarchy. +- The close button shows a visible focus ring in the automated screenshots. This is expected accessibility behavior from the capture path and should not be removed. + +**Implementation Checklist** +- Source visual truth path: `docs/ui-ux/qa/2026-07-02-figma-share-export-modal.png`. +- Implementation screenshot path: `docs/ui-ux/qa/2026-07-02-implementation-share-export-modal.png`. +- Success-state screenshot path: `docs/ui-ux/qa/2026-07-02-implementation-share-export-success-modal.png`. +- Mobile screenshot path: `docs/ui-ux/qa/2026-07-02-implementation-share-export-mobile.png`. +- Full-view comparison evidence: `docs/ui-ux/qa/2026-07-02-share-export-comparison.png`. +- Viewport: desktop `1440x900`; mobile `390x844 @ 2x`. +- State: default ready state and copied success state in demo mode. +- Focused region comparison evidence: modal-only screenshots were captured because the component-level source and implementation are readable without additional crops. +- Fonts and typography: system UI stack maps acceptably to the app; hierarchy, weight, wrapping, and line height remain readable in desktop and mobile captures. +- Spacing and layout rhythm: modal width, two-column desktop layout, section grouping, status strip, and footer alignment match the source intent. Export row density was compacted during QA. +- Colors and visual tokens: primary blue, slate text, light borders, neutral ready state, green success state, and disabled secondary action match the source intent and app palette. +- Image quality and asset fidelity: no external images or replacement assets are part of this modal; all visible UI is native controls and text. +- Copy and content: Korean implementation copy is coherent and maps to the same source states. PlantUML is the only added product row. +- Responsiveness: mobile capture has no horizontal overflow; the modal collapses to one column and remains scrollable. + +**Patches Made Since Previous QA Pass** +- Reduced `exportModal` vertical gaps and padding. +- Reduced share/export section padding and minimum height. +- Tightened export artifact row gaps, padding, and button sizing. + +**Follow-up Polish** +- Consider adding a dedicated Figma variant with PlantUML so the source design exactly reflects all current product export types. + +final result: passed diff --git a/docs/api-security-checklist.md b/docs/api-security-checklist.md index f2276dad..e16ed8e8 100644 --- a/docs/api-security-checklist.md +++ b/docs/api-security-checklist.md @@ -36,6 +36,8 @@ - ✅ Basic Auth 미사용 (OIDC Bearer Token 또는 dev fallback) - 근거: `backend/app/auth.py` +- ✅ `APP_ENV=production` startup guard가 OIDC issuer/audience 누락을 차단 + - 근거: `backend/app/settings.py`, `backend/app/main.py` - ✅ JWT 검증 시 알고리즘 allowlist 강제(토큰 헤더 `alg` 신뢰 금지) - 설정: `OIDC_ALGORITHMS` (default: `RS256`) - 근거: `backend/app/auth.py`, `backend/app/settings.py` @@ -46,8 +48,12 @@ - ✅ 프로젝트 리소스 접근은 멤버십 기반으로 제한 - 근거: `backend/app/permissions.py` 및 각 API handler의 `require_project_member(...)` -- 🟡 공유 링크(공개 엔드포인트)는 최소 권한(읽기)만 제공 +- ✅ 공유 링크(공개 엔드포인트)는 최소 권한(읽기)만 제공하고 기본 만료/폐기를 지원 + - 설정: `SHARE_LINK_DEFAULT_TTL_HOURS=168` (default, 0은 명시적 예외) - 근거: `backend/app/api/share.py` +- ✅ 공개 공유 링크의 live LLM draft는 기본 차단 + - 설정: `SHARE_LINK_LLM_DRAFT_ENABLED=false` (default) + - 근거: `backend/app/api/share.py`, `backend/tests/test_reversing_llm.py` ### CORS @@ -94,6 +100,30 @@ - 프록시/Ingress가 `X-Forwarded-For`를 신뢰 가능한 형태로 세팅/정제하는 경우에만 `true` - `API_RATE_LIMIT_MAX_KEYS` (default: `10000`) +- `SHARE_LINK_LLM_DRAFT_ENABLED` (default: `false`) + - 공개 공유 링크에서 `mode=llm-draft`가 외부 LLM provider 비용을 만들 수 있으므로, + 기본값은 차단입니다. 필요한 경우 별도 비용 한도/감사 로그/운영 승인을 갖춘 배포에서만 + `true`로 설정하세요. +- `SHARE_LINK_DEFAULT_TTL_HOURS` (default: `168`) + - 공개 공유 링크가 영구 bearer URL로 남지 않도록 기본 만료를 적용합니다. + - `0`은 만료 없음이므로 승인된 운영 예외에만 사용하세요. +- `LLM_MAX_PROMPT_CHARS` (default: `120000`) / `LLM_MAX_OUTPUT_TOKENS` + (default: `1200`) + - 인증된 live LLM draft에서도 provider 호출 전 prompt 크기와 출력 토큰 상한을 + 강제해 비용 폭주를 줄입니다. +- `LLM_DRAFT_QUOTA_ENABLED` (default: `true`) / + `LLM_DRAFT_QUOTA_REQUESTS` (default: `20`) / + `LLM_DRAFT_QUOTA_WINDOW_SECONDS` (default: `3600`) + - 인증 경로는 account UUID, 공유 경로는 share-link UUID 기준으로 fixed-window + quota를 적용합니다. + - production에서 live LLM provider가 설정되면 quota를 끌 수 없습니다. + - live draft 요청은 `llm_draft_requests_total`, input/output char histogram, + `event=llm_draft_usage` 로그로 기록되어 계정/스냅샷 단위 비용 조사가 가능합니다. +- `BILLING_ALLOWED_PLANS` (default: empty) + - 설정된 경우 plan-change 요청과 billing webhook `target_plan`을 configured + provider/customer catalog와 대조해 잘못된 plan ID를 `422`로 거절합니다. + - webhook 거절은 `billing_events_total{outcome="rejected_catalog"}`로 기록되어 + 운영 알림과 연결됩니다. ##### Trade-offs / 향후 계획 @@ -104,6 +134,9 @@ - 필요 시 2차 개선으로 Redis/Valkey 같은 공유 스토어 기반으로 확장합니다. - 🟡 HTTPS/TLS/HSTS는 인그레스/리버스프록시 계층에서 강제 필요 (앱 단독 강제는 한계) +- ✅ `APP_ENV=production` startup guard가 localhost/non-HTTPS CORS origin, 약한 + secret, 대상 DB allowlist 누락, 무기한 공유 링크 기본값을 차단 + - 근거: `backend/app/settings.py`, `backend/tests/test_production_config.py` ### Input validation / Data safety diff --git a/docs/business/krw-2b-market-kpi-model.md b/docs/business/krw-2b-market-kpi-model.md new file mode 100644 index 00000000..310ef777 --- /dev/null +++ b/docs/business/krw-2b-market-kpi-model.md @@ -0,0 +1,151 @@ +# KRW 2B Market And KPI Model + +This document defines the business evidence needed before positioning +`pg-erd-cloud` as a KRW 2B enterprise-grade SaaS or on-premises product. It is a +working model, not a sales claim. Any unsupported number must remain labeled as +an assumption until measured or sourced. + +## Decision Frame + +The commercial question is not only whether the software runs. A buyer or +acquirer needs evidence that the product can: + +- Solve a high-value database documentation and schema-understanding problem. +- Be deployed safely in SaaS and on-premises environments. +- Be operated and supported without founder-only knowledge. +- Enforce licensing, billing, security, and data-protection controls. +- Show credible adoption, usage, reliability, and retention signals. + +## ICP Segments + +| Segment | Need | Sale Motion | Evidence Status | +|---|---|---|---| +| Platform engineering teams | Keep database architecture understandable across services and teams | SaaS pilot to annual workspace contract | estimated | +| Data engineering teams | Document source schemas, snapshots, and change impact | SaaS team plan or enterprise workspace | estimated | +| SI and consulting teams | Reverse engineer customer databases and produce handoff artifacts | Per-seat or project-based on-premises package | estimated | +| Regulated enterprise DB teams | Keep ERDs and exports inside customer-controlled infrastructure | On-premises annual license plus support | estimated | +| Migration teams | Compare legacy and target schema structure during modernization | Time-boxed project license plus support | estimated | + +Missing evidence: + +- Customer interviews tied to each segment. +- Willingness-to-pay data. +- Competitive win/loss notes. +- Procurement constraints for regulated buyers. +- Support-cost data from real pilots. + +## KRW 2B Scenario Model + +These scenarios are planning assumptions. They should not be used as sales copy +until pricing data and customer evidence exist. + +| Scenario | Package | Path To KRW 2B | Evidence Status | +|---|---|---:|---| +| Enterprise SaaS | Annual workspace subscriptions with SSO, support, audit, and usage limits | 20 customers at KRW 100M annual contract value | estimated | +| On-premises enterprise | Offline license, support SLA, deployment package, and upgrade path | 10 customers at KRW 200M annual license plus support | estimated | +| SI channel | Partner license for repeat customer assessments | 5 partners at KRW 400M annualized channel value | missing | +| Acquisition or strategic sale | Product, IP, design system, tests, deployability, and customer evidence | KRW 2B valuation justified by product readiness and pipeline | missing | + +Required proof before using these scenarios externally: + +- Pricing experiments or signed letters of intent. +- At least one paid pilot or documented procurement discussion. +- Measured support and implementation effort. +- Security review completion evidence. +- On-premises installation drill evidence. + +## North-Star KPI + +Commercial readiness north-star: + +> Qualified database teams can create a trusted ERD, share/export it, and pass +> security/procurement review without direct maintainer intervention. + +This should be measured through a combination of activation, workflow success, +operational reliability, and support burden. + +## Product KPIs + +| KPI | Target For Paid Pilot | Target For GA | Current Status | +|---|---:|---:|---| +| Workspace activation rate | 60% of invited workspaces create one project | 75% | project creation measured by `product_events_total`; invite denominator missing | +| Connection setup success rate | 80% of attempts complete or fail with actionable guidance | 90% | connection creation measured by `product_events_total`; live validation denominator missing | +| Snapshot creation success rate | 90% for supported PostgreSQL/Snowflake paths | 95% | snapshot queueing measured by `product_events_total` and job metrics | +| ERD editor first render p95 | under 3 seconds for pilot-size schemas | under 2 seconds | missing | +| Share/export success rate | 95% for supported export paths | 98% | share-link creation measured by `product_events_total` and share audit metrics | +| License validation success | 99% valid commercial tokens accepted | 99.9% | partially measured by tests | +| Billing reconciliation success | 99% provider events applied or queued for support review | 99.9% | partially measured by tests and `billing_events_total` | +| LLM draft cost-control evidence | every live draft has usage evidence and provider failures alert | per-account quota and billing attribution | fixed-window account/share quota, monthly account attribution, and support diagnostics summary implemented; provider invoice reconciliation still needs pilot/provider data | +| Backup restore drill | one successful drill before paid pilot | quarterly successful drills | measured by restore drill manifest validator example; real pilot evidence pending | +| Incident first response | within one business day | SLA-specific response time | documented, not measured | +| Support touches per activation | under 2 support touches | under 1 | missing | + +## Guardrail Metrics + +| Guardrail | Why It Matters | Required Action | +|---|---|---| +| Auth failure spike | May indicate configuration, attack, or expired tenant setup | Alert and support runbook | +| Share-link failures | Buyer-facing trust and collaboration path | Alert and audit event correlation | +| Billing/license failures | Direct revenue and access-control risk | Provider reconciliation and support evidence | +| Snapshot job failure rate | Core product utility | Queue metrics and customer-visible recovery | +| Restore drill failure | On-premises and enterprise trust | Block commercial release until resolved | +| Visual regression failure | UX trust and buyer demo quality | Require approved baseline update or fix | + +## Measurement Plan + +1. Add event taxonomy for activation, connection setup, snapshot job, share, + export, license validation, billing event, restore drill, and support action. + Initial low-cardinality lifecycle events now use + `product_events_total(area, action, outcome)` for project, connection, + snapshot, and share-link creation. + Restore drill evidence now uses `docs/operations/restore-drills/*.json` + manifests validated by `scripts/ci/validate_restore_drill_manifest.py`. +2. Keep customer identifiers redacted or pseudonymous in telemetry. +3. Make each KPI auditable from logs, metrics, or release artifacts. +4. Add sample dashboard queries after the event taxonomy is implemented. +5. Use pilot evidence to replace assumptions with measured values. + +## Immediate Product Implications + +- Billing provider reconciliation remains a P1 gap until provider-specific + fulfillment, customer portal deep integration, and real provider catalog + approval exist. The checkout handoff API, common event recording path, + raw-body HMAC signature verification path, configurable provider event alias + normalization, configurable entitlement evidence and member-invite seat-limit + enforcement, configurable plan catalog validation, billing provider catalog + manifest gate, normalized contract-state application path, and billing reconciliation + outcome metrics are now covered by tests or release validators. +- Admin/support diagnostics are now partially measured by tests through the + backend read-only diagnostics API, operator-only frontend view, demo-mode E2E + support lookup with recent share-link evidence, and Product Design/Figma audit + evidence in + `docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/`. PR #415 also has a + commercial readiness evidence board in Figma at + , with a + repository screenshot at + `docs/ui-ux/qa/2026-07-02-commercial-readiness-evidence-board.png`. Support + workload and resolution-time metrics still need pilot data. +- On-premises packaging drills are a P1 gap because regulated buyers need proof + that the product can run and be recovered in their environment. +- Figma/Product Design work must include buyer-trust states, not only the happy + path: empty workspace, connection failure, billing limit, account + deactivation, share/export success, and support escalation. + +## Evidence Status Summary + +- Measured today: local automated tests for license, usage limits, account + deactivation, checkout/plan-change handoff, provider-neutral billing event ingestion, + provider event alias normalization, entitlement evidence, member-invite seat-limit + enforcement, plan catalog validation, billing webhook outcome metrics, + product lifecycle outcome metrics for project/connection/snapshot/share-link + creation, LLM draft usage/failure metrics, audit logs, monthly account + attribution API, support-visible current-month LLM usage, + read-only support diagnostics with recent share-link summaries and redacted + provider metadata summaries, + operator-only support diagnostics UI, support diagnostics demo evidence, + visual regression, accessibility, and E2E smoke paths. +- Estimated today: ICP fit, packaging mix, enterprise price points, activation + rates, support effort, and buyer conversion. +- Missing today: real customer interviews, paid pilot data, procurement notes, + pricing sensitivity, support workload, production telemetry, and provider invoice + reconciliation data. diff --git a/docs/commercial-readiness.md b/docs/commercial-readiness.md new file mode 100644 index 00000000..d8b99968 --- /dev/null +++ b/docs/commercial-readiness.md @@ -0,0 +1,365 @@ +# Commercial Readiness Plan + +이 문서는 `pg-erd-cloud`를 판매 가능한 SaaS 또는 온프레미스 제공물로 만들기 위한 +구체 기준과 현재 결론을 정리합니다. 자동 리뷰 봇이나 장시간 분석 봇의 지연은 +출시 blocker로 보지 않고, 보안/비용/데이터/운영/법무/지원 관점의 실제 결함만 +blocker로 분류합니다. + +## Current Verdict + +현재 제품은 실행 가능한 초기 제품 골격을 갖췄지만, 그대로 판매 가능한 프로그램은 아닙니다. +핵심 이유는 보안/인증/운영 체계가 모두 완성 단계에 있지 않기 때문입니다. + +## KRW 2B Commercialization Track + +`pg-erd-cloud`를 KRW 2B급 엔터프라이즈 SaaS/온프레미스 제품으로 평가받을 수 +있게 만들기 위한 구체 실행 계획은 +`docs/superpowers/plans/2026-07-02-krw-2b-commercialization.md`에 둡니다. +이 기준에서 KRW 2B는 매출 보장이 아니라, 구매자·투자자·전략적 인수자가 +엔터프라이즈급 제품 패키지로 신뢰할 수 있는 완성도 기준입니다. + +관련 산출물: + +- KRW 2B 실행 계획: + `docs/superpowers/plans/2026-07-02-krw-2b-commercialization.md` +- 시장/KPI 모델: + `docs/business/krw-2b-market-kpi-model.md` +- Product Design 지원 진단 감사: + `docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/README.md` +- PR #415 상용화 증거 Figma 섹션: + +- PR #415 상용화 증거 스크린샷: + `docs/ui-ux/qa/2026-07-02-commercial-readiness-evidence-board.png` +- FigJam 운영 모델: + + +현재 결정: + +- Figma Code Connect는 사용하지 않습니다. +- 새 git submodule은 만들지 않습니다. 독립 배포·별도 버전관리·복수 소비자가 + 검증되기 전까지는 repo 내부 모듈과 문서/테스트 gate로 관리합니다. +- 자동 리뷰 봇 대기와 queued 분석 지연은 blocker가 아닙니다. 실제 실패한 CI, + 보안 결함, 미구현 판매 필수 기능, 검증 불가능한 운영 경로만 blocker입니다. + +## 실시간 감사(2026-07-02 UTC 기준) + +- `main` 브랜치 최근 실행(40건 기준): **39건 성공, 1건 실패**. 실패는 `codeql-sast-backfill`의 과거 단일 실패(2026-07-01T00:55Z)이며, 현재 `main`은 연속 성공 라인입니다. +- 현재 open PR 상태: + - 총 **81개** PR + - `reviewDecision`: `REVIEW_REQUIRED` 46개, `CHANGES_REQUESTED` 27개, `APPROVED` 6개 + - `mergeStateStatus`: `BLOCKED` 32개, `DIRTY` 49개 + - 리뷰 스레드 기준 **미해결 27개**, 중복 포함 아님 +- 미해결 thread가 있는 PR 분포는 `BLOCKED` 상태 10개, `DIRTY` 상태 5개입니다. +- 미해결 thread 작성자 분포: + - `github-code-quality`: 12개 + - `copilot-pull-request-reviewer`: 11개 + - `github-actions`: 4개 + - 수동 작성자(사람) 미해결 thread는 현재 0개로 집계됨 +- 판단: **자동 리뷰 지연·품질봇 코멘트는 원칙대로 blocker에서 제외**하되, 해당 코멘트가 요구하는 실제 개선(예: 접근성/성능/테스트 규칙)은 별도 이슈로 반영하여 순차 처리. + +## Product Design/Figma Evidence Refresh (2026-07-02) + +- Figma Code Connect는 사용하지 않았습니다. +- `VITE_DEMO_MODE=true`와 `/?demo-support=operator`로 support operator 전용 + read-only 결제/계정 진단 흐름을 재현할 수 있게 했습니다. 이 경로는 데모와 + Product Design 감사 전용이며 production authorization을 우회하지 않습니다. +- 현재 run에서 캡처한 증거는 + `docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/`에 저장했고, Figma + 디자인 파일의 `Support diagnostics commercial-readiness audit` 섹션 + 에 배치했습니다. +- PR #415의 상용화 구현 증거, KPI 계획, GitHub 대기 상태, 잔여 sale-quality + blocker는 Figma의 `PR #415 Commercial Readiness Evidence` 섹션 + 에 배치했고, + 검증 스크린샷을 + `docs/ui-ux/qa/2026-07-02-commercial-readiness-evidence-board.png`로 보존했습니다. + 이 섹션도 Figma Code Connect 없이 생성했습니다. +- `Support bundle evidence · 2026-07-03` + 프레임에 + redaction-first support bundle gate, CI 연결, 검증 결과, 잔여 no-go 항목을 추가했습니다. + 이 갱신도 Figma Code Connect 없이 수행했습니다. +- desktop support diagnostics는 paid-pilot 지원 데모에 사용할 수 있는 수준입니다. + support/billing/reactivation destination은 raw URL 대신 named link와 URL copy + action으로 표시합니다. +- narrow viewport에서도 billing event의 provider/event/plan/received context를 + stacked row label로 보존합니다. `stress-customer` fixture와 Playwright overflow + 검증으로 긴 provider event, 계약 식별자, plan name, timestamp, redacted provider + metadata summary 증거를 추가했습니다. 일반 판매 전 잔여 과제는 실제 provider + payload 기반 브라우저 리뷰입니다. + +## 상업 릴리즈 진입 판정(실시간) + +현재 기준일(UTC): 2026-07-02 04:09 + +### No-Go 항목 (판매 즉시 블로커) + +1. **P0 인증·인가 완성도**: 현재는 OIDC 검증 강제, API 허가, 공유 경로 제한, 계정 비활성화 사용자 안내, 알림 임계치 운영 템플릿이 적용되어 있음. +2. **P2 라이선스/과금 운영 연동**: `LICENSE_MODE`, Ed25519 서명 토큰 검증, CLI 기반 발급/재발급, env 기반 토큰/고객 회수 목록, 사용량 summary API, 기본 사용량 한도 enforcement, billing/reactivation URL 노출, checkout/plan-change handoff, provider-neutral billing event reconciliation, raw-body HMAC signature 검증, configurable provider event alias normalization, configurable entitlement evidence, configurable plan catalog validation, normalized contract-state event 적용은 구현됐으나, provider별 fulfillment SDK, customer portal 심화 연동, 실제 provider catalog 운영값 주입은 미구현. +3. **지원/법무 패키지 승인 기록**: 약관/개인정보/SLA/보안 신고/승인 체크리스트와 release approval manifest CI 검증은 준비됐으나, 실제 판매 버전의 승인자 서명 기록은 아직 없음. +4. **릴리즈별 승인·운영 자동화 잔여분**: 법무/지원 승인 manifest 검증, desktop/mobile/first-run UI 회귀 자동화, baseline 승인 절차, subject 기반 계정 비활성화는 들어갔으나, 실제 판매 릴리즈별 승인 기록과 포털 기반 재활성화 자동화는 아직 필요. + +### 우선순위 기반 실행 계획(현재 PR과 분리) + +| 우선순위 | 항목 | 현재 상태 | 다음 조치 | +|---|---|---|---| +| P0 | 인증/인가/오류 처리 | 기준 충족 | 릴리즈 환경별 threshold 승인 기록 유지 | +| P1 | 라이선스·사용량·과금 체계 | 서명 토큰 발급 CLI + 검증 + env 기반 회수 목록 + 사용량 summary API + 기본 한도 enforcement + billing/reactivation URL + checkout/plan-change handoff + provider-neutral billing event 기록 + HMAC signature 검증 + configurable provider event alias normalization + configurable entitlement evidence + configurable plan catalog validation + normalized contract-state event 적용 | provider별 fulfillment SDK + customer portal 심화 연동 + 실제 provider catalog 운영값 주입 | +| P1 | 운영 자동화 | 부분 완료 | 장애 대응·백업·마이그레이션 절차를 CI 재시작 플로우와 연결 | +| P2 | 법무 문서 고도화 | 약관/개인정보/SLA/보안 신고/승인 gate 문서화 + manifest CI 검증 | 지역/계약별 실제 승인 기록 첨부 | +| P2 | 품질 게이트 | 접근성 + 브라우저 E2E smoke + desktop/mobile editor baseline + first-run dashboard baseline + share/export modal baseline + baseline 갱신 승인 절차 도입 | 추가 브라우저/핵심 workflow baseline 검토 | + +위 항목 중 하나라도 미완이면 “판매 전 검수 합격”으로 보기 어렵습니다. + +### Current Control Board (2026-07-02 UTC) + +- P0: `in_progress` (공개 비용 방지와 핵심 설정 강제는 완료했고, 인증 실패 + 대응 메시지/알람 통일과 일부 운영 임계치 보강은 진행 중) +- P1: `in_progress` (공유 링크 감사 로그, migration rollback, 장애 대응 runbook은 + 1차 진행 중) +- P2: `in_progress` (결제/라이선스, 보안 운영 SLA, 법무 승인 기록 체계 정리 진행 중) + +## Release Gates + +### P0: 판매 전 필수 + +- 인증: 운영 환경에서 OIDC issuer/audience/alg allowlist가 강제되고, 미설정 시 + 명확히 실패해야 합니다. +- 인가: 프로젝트 멤버십, 오너 권한, 공유 링크 권한이 테스트로 보호되어야 합니다. +- 데이터 보호: 저장 DSN 암호화, secret 파일 주입, DSN/error redaction, 대상 DB + allowlist가 기본 방어선으로 동작해야 합니다. +- 공개 엔드포인트 남용 방지: `/api/share/*`는 별도 rate limit과 기본 만료를 적용하고, + 외부 비용을 만들 수 있는 live LLM draft는 기본 차단해야 합니다. +- 배포 재현성: Docker/prod compose, backend/frontend lockfile, 런타임 버전이 일관되어야 합니다. +- 검증: backend mypy/pytest, frontend typecheck/test/build가 main 기준에서 통과해야 합니다. + +현재 상태 + +- ✅ `SHARE_LINK_LLM_DRAFT_ENABLED=false` 기본값 및 공용 공유의 LLM 비용 방지 +- ✅ `APP_ENV=production` startup guard로 핵심 보안 설정 강제 +- ✅ `Llm` prompt/output 상한으로 비용 탐색 경로 제한 +- ✅ `share 링크` TTL 기본값 7일(`SHARE_LINK_DEFAULT_TTL_HOURS=168`) 적용 +- ✅ 인증 실패 응답(`authz_failure`) 관측 이벤트 로깅 추가 +- ✅ 공유 감사(`share_audit`)에 `request_id`를 포함해 지원 티켓/로그 상관관계 분석 강화 +- ✅ 공개 공유 링크 LLM-draft 차단/실패 이벤트의 공유 감사 로그 보강(액션·결과·에러코드) +- ✅ 인증/계정 상태 사용자 안내: `getMe` 실패 시 raw HTTP 오류 대신 인증 필요, + 권한 없음, 계정 비활성화 상태를 구분하고 billing/reactivation 링크를 표시함 +- ✅ 보안/운영 알림 임계치: `docs/operations/alert-thresholds.md`로 alert별 severity, + 고객 영향, 1차 대응, owner, escalation rule을 고정하고 release approval manifest의 + 필수 운영 문서로 검증함 +- 🆕 온프레미스 패키지 gate: `docs/operations/on-premises-package.md`와 + `scripts/ci/validate_onprem_package.py`로 production compose, secret file, + offline license, revocation, restore drill, rollback drill, support bundle + 문서/설정 누락을 CI에서 검증함 +- 🆕 복구 drill evidence gate: `docs/operations/restore-drills/restore-drill.example.json`와 + `scripts/ci/validate_restore_drill_manifest.py`로 backup artifact SHA-256, + 격리 restore target, secret source, `/healthz`, project/share/export/support bundle + smoke 결과를 기계 검증 가능한 manifest로 고정함 +- 🆕 Rollback drill evidence gate: + `docs/operations/rollback-drills/rollback-drill.example.json`와 + `scripts/ci/validate_rollback_drill_manifest.py`로 rollback 전 backup artifact, + `alembic downgrade --sql` dry-run, 실제 rollback revision 결과, `/healthz`, + project/snapshot/share/export/support bundle smoke를 기계 검증 가능한 manifest로 + 고정함 +- 🆕 지원 bundle 생성 gate: `scripts/operations/generate_support_bundle.py`와 + `scripts/operations/test_generate_support_bundle.py`로 배포 commit, billing provider + catalog version, `alembic current`, `/healthz`, backend error log, support diagnostics + JSON을 하나의 redacted bundle로 만들고 raw secret/DSN/provider metadata/share URL + 누출을 테스트로 차단함 +- 🆕 지원 bundle evidence gate: + `docs/operations/support-bundles/support-bundle.example.json`와 + `scripts/ci/validate_support_bundle.py`로 생성된 support bundle의 필수 필드, + compose digest, redaction.applied, 민감 key/value redaction을 CI에서 검증함 + `python scripts/ci/validate_support_bundle.py evidence/support-bundle.json`처럼 + 실제 고객/스테이징에서 생성한 bundle 파일도 같은 규칙으로 직접 검증할 수 있음 +- 🆕 판매 가능성 audit: + `scripts/ci/commercial_readiness_audit.py`로 schema validator 통과 여부와 + 실제 판매 증거(승인 manifest, restore drill, rollback drill, support bundle, + billing provider catalog)가 예시-only인지 실제 파일인지 구분함. 기본 실행은 report를 만들고, + `--strict`는 실제 판매 증거가 부족하면 실패함. 고객/스테이징 증거는 저장소에 + 커밋하지 않고 `--release-approval`, `--restore-drill`, `--rollback-drill`, + `--support-bundle`, `--billing-provider-catalog`, `--billing-runtime-evidence` + 경로로 직접 주입할 수 있으며, `*.example.json` + 이름 또는 `example.com`, fake commit SHA, 반복 SHA-256, `customer-acme` + 같은 샘플 표식이 남은 파일은 실제 판매 증거로 집계하지 않음. 저장소에 커밋한 + non-example 증거 파일도 같은 validator와 샘플 표식 검사를 통과해야 함. audit + JSON의 `sale_blockers`는 validator 실패와 실제 증거 no-go 이유를 최상위에서 + 요약해 판매 불가 사유를 바로 확인할 수 있게 함 +- 🆕 상용 증거 index: + `scripts/operations/build_commercial_evidence_index.py`로 외부 release approval, + restore drill, rollback drill, support bundle, billing provider catalog, + billing runtime evidence 파일의 + path, size, SHA-256, gate 상태, `sale_blockers`를 하나의 JSON으로 묶음. 증거 파일 + 본문은 복사하지 않아 support bundle 또는 provider metadata의 재노출을 피하면서 + 어떤 exact 증거 세트로 판매 승인했는지 재검증할 수 있음 +- 🆕 Billing runtime evidence: + `scripts/operations/build_billing_runtime_evidence.py`와 + `scripts/ci/validate_billing_runtime_evidence.py`로 승인된 billing provider catalog와 + 배포 env 파일이 일치하는지 raw URL/secret 값을 복사하지 않고 SHA-256 비교 결과와 + blocker 목록으로 증거화함. `docs/operations/billing-runtime.env.example`은 safe + smoke fixture이며, 실제 판매 증거는 고객/스테이징 env와 real provider catalog로 + 생성해야 함 +- 🆕 운영 감시 항목 보완: authz 실패/공유 감사 이벤트 메트릭(`authz_failures_total`, `share_audit_events_total`)을 추가해 알람 임계치 운영을 시작함 +- 🆕 결제 감시 항목 보완: billing reconciliation outcome 메트릭(`billing_events_total`)과 + `PgErdCloudBillingWebhookFailures` alert로 webhook 인증/설정 실패를 유료 pilot 전 + 운영자가 볼 수 있게 함 +- 🆕 제품 KPI 측정 보완: project/connection/snapshot/share-link 생성 흐름을 + `product_events_total(area, action, outcome)`로 기록해 activation, setup, + snapshot queueing, share-link creation의 성공/거절 분포를 파일럿에서 집계할 수 있음 + +### P1: 유료 베타 필수 + +- 공유 링크별 감사 로그를 제공해야 합니다. +- migration rollback policy와 장애 대응 runbook을 문서화해야 합니다. +- LLM draft 사용량 감사 로그와 실패율 알림 기준을 운영 문서와 테스트로 고정해야 합니다. +- 설치/운영 문서에서 초기 제품 단계 표현을 제거하고, 지원 범위와 미지원 범위를 명시해야 합니다. + +현재 상태 + +- ✅ 공유 링크 운영 감사 로그 JSON 이벤트(`event=share_audit`) 추가 및 경로별 테스트 + (공유 live LLM draft 성공 경로 포함) +- ✅ `docs/operations/backup-restore.md` 추가로 앱 DB 복구 절차 기초 정립 +- ✅ `docs/operations/migration-rollback.md` 작성 및 운영 복구 문서화 +- ✅ `docs/operations/incident-response.md` 작성 및 1차 대응 흐름 정리 +- ✅ 운영 알림: `deploy/prometheus/pg-erd-cloud-alerts.yml`에 HTTP 5xx, p95 지연, + 인증/인가 실패, 공유 링크 실패/거부, LLM draft 실패, job 실패/대기시간 alert rule을 추가함 +- 🆕 LLM 비용/사용량 관측성: live LLM draft 경로에서 + `llm_draft_requests_total`, input/output char histogram, `event=llm_draft_usage` + 구조화 로그를 기록해 계정/스냅샷 단위 비용 조사를 할 수 있음 +- 🆕 LLM draft fixed-window hard gate: 인증 경로는 account UUID, 공유 경로는 + share-link UUID 기준으로 `LLM_DRAFT_QUOTA_*`를 적용하고, 초과 시 provider 호출 전 + `429`와 `event=llm_draft_usage outcome=quota_exceeded`를 기록함 +- 🆕 LLM 월간 billing attribution: `llm_draft_usage_event` ledger와 + `GET /api/billing/llm-usage?month=YYYY-MM`를 추가해 account 단위 요청 수, + 성공/실패/quota 초과, input/output 문자량을 provider invoice 대조용으로 조회하고, + support diagnostics에서도 현재 월 계정별 LLM usage summary를 표시함 +- 🟡 provider 과금 연동은 여전히 실제 provider invoice/catalog와 대조하는 fulfillment + 단계가 필요함 +- 🆕 PR #415 상용화 증거 보드: 구현 완료 항목, Product Design 증거, Data + Analytics/KPI 계획, GitHub queued 상태, 잔여 sale-quality blocker를 Figma + 섹션과 repository screenshot으로 보존함 + +### P2: 일반 판매 권장 + +- 결제/라이선스/좌석 관리 또는 온프레미스 license token 검증 경로를 제공해야 합니다. +- SLA, 지원 채널, 보안 취약점 처리 정책, 개인정보 처리 문서, 약관을 배포물에 포함해야 합니다. +- 시각 회귀 테스트, 브라우저 E2E, 접근성 스모크 테스트를 CI release gate로 운영해야 합니다. +- 관리자가 프로젝트/멤버/공유 링크/사용량을 검색하고 제한할 수 있어야 합니다. + +현재 상태 + +- ✅ 온프레미스 라이선스: `LICENSE_PUBLIC_KEY` 기반 Ed25519 서명 토큰 검증을 추가해 + 고객/플랜/토큰 ID/만료/활성 시작/시트 claim을 오프라인에서 검증할 수 있음 +- ✅ 라이선스 발급/재발급: `python -m app.license_tokens` CLI로 Ed25519 keypair 생성과 + 계약별 signed token 발급을 수행할 수 있음 +- ✅ 라이선스 회수: `LICENSE_REVOKED_TOKEN_IDS`, `LICENSE_REVOKED_SUBJECTS`로 특정 + `jti` 또는 고객 `sub`를 만료 전 회수할 수 있음 +- ✅ 사용량 집계: `GET /api/billing/usage`로 소유 프로젝트 범위의 프로젝트/시트/연결/ + 스냅샷/공유 링크/활성 공유 링크 수, 적용 한도, 라이선스 검증 방식을 반환함 +- ✅ 사용량 제한: 프로젝트/연결/스냅샷/공유 링크 생성 시 `BILLING_MAX_*` 환경 변수 + 기반 한도를 적용할 수 있음 +- ✅ 계정 비활성화: `ACCOUNT_DEACTIVATED_SUBJECTS`로 계약 중단/미납/abuse 대상 OIDC + subject를 DB 접근 전 403으로 차단할 수 있음 +- ✅ 계정 재활성화 경로: `BILLING_PORTAL_URL`, `BILLING_SUPPORT_URL`, + `ACCOUNT_REACTIVATION_URL`을 `/api/billing/usage`와 account-deactivated 응답 헤더로 + 노출하며, production에서 비활성 목록을 쓰면 재활성화 또는 지원 URL을 요구함 +- ✅ 요금제 변경 handoff: `POST /api/billing/plan-change`로 target plan을 검증하고 + `BILLING_PORTAL_URL` 또는 `BILLING_SUPPORT_URL` 기반 실행 경로를 반환함 +- ✅ Checkout handoff: `POST /api/billing/checkout`으로 target plan을 검증하고 + `BILLING_CHECKOUT_URL` 또는 `BILLING_SUPPORT_URL` 기반 구매 시작 경로를 반환함 +- 🆕 결제 reconciliation: `POST /api/billing/events`로 shared secret 또는 raw-body + HMAC-SHA256 signature 기반 provider-neutral event를 기록하고, + `(provider, provider_event_id)` 중복을 무시하며, 민감 metadata를 redaction해 + 지원/정산 증거를 남김 +- 🆕 결제 reconciliation 관측성: billing webhook `recorded`, `duplicate`, + `rejected_auth`, `rejected_config`, `rejected_catalog` outcome을 + `billing_events_total`로 기록하고 + 실패 outcome에 대한 Prometheus alert를 제공함 +- 🆕 계약 상태 적용: `BILLING_CONTRACT_STATE_EVENTS_ENABLED=true`에서 + normalized `contract.suspended`/`contract.deactivated`류 event를 최신 상태로 + 적용해 해당 subject의 API 접근을 `account deactivated`로 차단하고, + `contract.reactivated`/`contract.activated`류 event가 최신이면 차단을 해제함 +- 🆕 Provider event normalization: `BILLING_EVENT_TYPE_ALIASES`로 + `source=target` 또는 `provider:source=target` 별칭을 설정해 Stripe/Paddle 등 + 공급자 원본 event_type을 저장 전에 normalized contract/billing event_type으로 + 바꾸고, 원본 event_type은 `raw_event_type` metadata로 감사 보존함 +- 🆕 Plan catalog validation: `BILLING_ALLOWED_PLANS`로 plan-change 요청과 billing + webhook `target_plan`을 configured provider/customer catalog에 대조하고, catalog + drift는 `rejected_catalog` metric/alert로 노출함 +- 🆕 Billing provider catalog gate: + `docs/operations/billing-provider-catalog.example.json`와 + `scripts/ci/validate_billing_provider_catalog.py`로 checkout/portal/support URL, + allowed plan, entitlement event, contract-state event, webhook secret storage + reference 누락을 CI에서 검증함. 실제 provider catalog는 + `python scripts/ci/validate_billing_provider_catalog.py evidence/billing-provider-catalog.customer.json` + 처럼 외부 증거 경로로 직접 검증할 수 있음 +- 🆕 Billing entitlement evidence/enforcement: `BILLING_ENTITLEMENT_EVENT_TYPES`에 + 포함된 최신 billing event의 `target_plan`과 metadata `seat_count`/`seats`류 값을 + support diagnostics에서 현재 plan/contracted seat evidence로 표시하고, 프로젝트 + 멤버 초대 시 계약 seat 수를 초과하는 신규 subject를 차단함 +- 🆕 지원 진단: `SUPPORT_OPERATOR_SUBJECTS` allowlist 기반 + `GET /api/billing/support/account` read-only API로 대상 계정 상태, usage counter, + license verifier, billing/reactivation URL, entitlement evidence, 현재 월 LLM draft + usage summary, 최근 share link summary, 최근 billing event summary, redacted provider + metadata summary를 조회할 수 있음 +- 🆕 운영자 UI: `/api/me`의 `support_operator` flag로 허용된 운영자에게만 + 프론트엔드 `지원 진단` 화면을 노출하고, raw billing metadata와 공개 share URL 없이 + read-only 계정/사용량/LLM usage/최근 share link/최근 event/redacted metadata + summary를 표시함 +- 🆕 지원 진단 데모/감사 경로: `VITE_DEMO_MODE=true`에서 + `/?demo-support=operator`로 Product Design, Figma, sales-engineering 감사용 + operator 화면과 billing event sample을 재현할 수 있으며, Playwright E2E로 + 조회 흐름을 검증함 +- 🟡 결제/라이선스: 정적 `LICENSE_KEY`는 기존 배포 호환용으로 유지하며, 외부 결제 + provider별 fulfillment SDK, customer portal 심화 연동, 실제 provider catalog 운영값 + 승인, 고객 승인까지 포함한 자동 seat provisioning/deprovisioning은 추가 설계 중. + 다만 support diagnostics는 최신 entitlement의 계약 좌석 수와 실제 active seat를 + 비교하고 초과 시 read-only deprovisioning 후보를 반환함 +- ✅ 법무/지원 패키지: 개인정보 처리, 이용약관, SLA/지원, 보안 취약점 신고, + 상용 릴리즈 승인 체크리스트를 배포물에 포함함 +- ✅ 승인 기록 형식 검증: `docs/legal/release-approvals/release-approval.example.json`과 + `scripts/ci/validate_commercial_release_approval.py`를 추가해 릴리즈별 승인 manifest + 구조, 승인 필드, 문서 경로를 CI에서 검증함 +- 🟡 법무 승인: 실제 판매 버전의 승인자/일자/계약 범위 기록은 릴리즈별로 첨부 필요 +- ✅ 접근성 smoke: `npm run test:a11y`와 CI `Accessibility smoke` 단계를 추가해 + skip link, main landmark, navigation state, editor toolbar accessible names, modal focus trap을 검증함 +- ✅ 브라우저 E2E smoke: `npm run test:e2e`와 CI `Browser E2E smoke` 단계를 추가해 + demo workspace load, editor toolbar interaction, screenshot rendering을 Chromium에서 검증함 +- ✅ 시각 회귀 gate: `npm run test:visual`과 CI `Visual regression` 단계로 demo editor + desktop 1280x720, mobile review 390x844, first-run empty dashboard 1280x720, + share/export modal 1280x720 픽셀 baseline을 검증함 +- ✅ baseline 갱신 승인 절차: `docs/ui-ux/visual-regression-baseline.md`에 변경 사유, + Figma/참조 증거, 브라우저 증거, 승인자, No-Go 조건, 갱신 명령 순서를 고정함 +- 🟡 고급 운영 테스트: Firefox/WebKit 등 추가 브라우저와 연결 생성/스냅샷 생성 등 + 다른 핵심 workflow별 baseline은 후속 보강 필요 + +## First Implementation Slice + +이번 2차 반복 구현은 P0의 “공개 엔드포인트 비용/노출 남용 방지”와 P1의 +공유 링크 감사 추적을 닫는 작업을 함께 진행합니다. + +- `SHARE_LINK_LLM_DRAFT_ENABLED=false`를 기본값으로 추가합니다. +- 공개 공유 링크의 `mode=llm-draft` 호출은 기본적으로 `403`으로 차단합니다. +- 인증된 snapshot API의 live LLM draft는 기존 동작을 유지합니다. +- `SHARE_LINK_DEFAULT_TTL_HOURS=168`을 기본값으로 추가해 공개 공유 링크를 7일 뒤 + 만료시킵니다. +- 프로젝트 오너가 공유 링크를 목록 조회하고 삭제 방식으로 폐기할 수 있게 합니다. +- `APP_ENV=production` startup guard로 OIDC, 공개 HTTPS CORS origin, 강한 secret, + 대상 DB allowlist, 공유 링크 기본 만료를 강제합니다. +- production startup guard는 billing checkout/portal/support/reactivation URL도 공개 HTTPS로 + 제한하고, billing webhook secret 길이와 contract-state webhook 인증 수단을 + 강제합니다. +- 인증된 live LLM draft도 `LLM_MAX_PROMPT_CHARS`와 `LLM_MAX_OUTPUT_TOKENS`로 + provider 호출 전 비용 상한을 두며, `LLM_DRAFT_QUOTA_*` fixed-window quota를 + 통과해야 provider를 호출합니다. +- 앱 메타데이터 PostgreSQL backup/restore runbook을 추가합니다. +- 운영자가 공개 공유 링크 LLM draft를 의도적으로 열 수는 있지만, + `SHARE_LINK_LLM_DRAFT_ENABLED=true`와 `LLM_DRAFT_QUOTA_*` 비용 한도, 감사 로그, + 운영 승인 정책을 갖춘 배포에서만 사용하도록 문서화합니다. +- 공개 공유 링크 감사 로그는 모든 성공/실패 동작에서 JSON 이벤트로 기록되며, + 실시간 모니터링 연계 시 사용 가능한 형태로 유지합니다. + +## Ongoing Execution Rules + +- 리뷰 봇 대기, 장시간 정적 분석 대기, 비필수 자동화 지연은 blocker가 아닙니다. +- blocker는 고객 데이터 노출, 비용 무제한 발생, 인증/인가 우회, 배포 불능, 핵심 + 검증 실패, 법무/지원 문서 부재처럼 판매 가능성을 직접 막는 항목입니다. +- 각 반복은 하나 이상의 blocker 또는 P1 gap을 코드/문서/테스트로 닫고 PR에 남깁니다. diff --git a/docs/legal/commercial-release-approval.md b/docs/legal/commercial-release-approval.md new file mode 100644 index 00000000..26c3dd39 --- /dev/null +++ b/docs/legal/commercial-release-approval.md @@ -0,0 +1,68 @@ +# 상용 릴리즈 법무/지원 승인 체크리스트 + +이 문서는 pg-erd-cloud를 유료 SaaS 또는 온프레미스 패키지로 판매하기 전에 +릴리즈 책임자가 확인해야 하는 승인 gate입니다. 법률 자문을 대체하지 않으며, +승인 기록이 없으면 일반 판매를 진행하지 않습니다. + +## 1) 필수 승인 산출물 + +- 이용약관: `docs/legal/terms-of-service.md` +- 개인정보 처리 안내: `docs/legal/privacy-policy.md` +- SLA/지원 기준: `docs/legal/sla-support.md` +- 라이선스/결제 운영 기준: `docs/legal/license-billing.md` +- 결제 provider catalog manifest: + `docs/operations/billing-provider-catalog.example.json` +- 보안 취약점 신고 정책: `SECURITY.md` +- 운영 runbook: + - `docs/operations/incident-response.md` + - `docs/operations/backup-restore.md` + - `docs/operations/migration-rollback.md` + - `docs/operations/on-premises-package.md` + - `docs/operations/alert-thresholds.md` + +## 2) 판매 전 필수 결정 + +- 판매 형태: SaaS, 온프레미스, 또는 하이브리드 +- 계약 플랜: 평가판, 유료 베타, 일반 판매, 엔터프라이즈 +- 결제 방식: 수동 인보이스, 외부 결제 대행, 또는 리셀러 계약 +- 결제/계약 provider catalog: allowed plan, checkout/portal/support URL, entitlement + event, contract-state event, webhook secret storage reference +- 데이터 처리 범위: 앱 메타데이터, 접속 DSN, 스냅샷 JSON, 감사 로그, 지원 로그 +- 보존 기간: 프로젝트/스냅샷/공유 링크/감사 로그/백업별 보존 기간 +- 지원 채널: 계약 이메일, 티켓 시스템, 보안 advisory, 긴급 연락 경로 +- 취약점 대응 소유자: 접수, triage, 패치, 고객 통보 책임자 +- 하위 처리자: 결제, 분석, 고객지원, 호스팅, LLM provider 사용 여부 + +## 3) 승인 기록 + +릴리즈 전 다음 항목을 릴리즈 이슈 또는 계약 승인 문서에 남깁니다. +판매 대상 릴리즈를 저장소에 기록할 때는 +`docs/legal/release-approvals/release-approval.example.json`을 복사해 +릴리즈별 manifest를 만들고, `scripts/ci/validate_commercial_release_approval.py`가 +CI에서 통과해야 합니다. example 파일은 형식 검증용이며 실제 승인 기록으로 +간주하지 않습니다. + +| 항목 | 값 | +|---|---| +| 릴리즈 버전/커밋 | | +| 판매 형태 | | +| 승인 일자 | | +| 제품 책임자 | | +| 법무/계약 승인자 | | +| 보안 승인자 | | +| 지원 운영 승인자 | | +| 적용 약관/개인정보/SLA 문서 버전 | | +| 알려진 미지원 범위 | | +| 고객 통지 필요 사항 | | + +## 4) No-Go 조건 + +다음 중 하나라도 해당하면 일반 판매를 보류합니다. + +- 승인 기록이 없거나 승인자가 비어 있음 +- 개인정보 처리 안내가 실제 배포의 제3자 처리자와 맞지 않음 +- SLA/지원 기준에 응답 시간, 제외 범위, 긴급 연락 경로가 없음 +- 라이선스 발급/회수 절차와 고객 계약 조건이 불일치함 +- 실제 결제/계약 catalog manifest가 없거나 배포 env와 불일치함 +- 보안 취약점 신고 경로 또는 triage 책임자가 없음 +- 운영 runbook 없이 데이터 복구, 배포 롤백, 장애 통지가 필요한 계약을 판매함 diff --git a/docs/legal/license-billing.md b/docs/legal/license-billing.md new file mode 100644 index 00000000..2101d728 --- /dev/null +++ b/docs/legal/license-billing.md @@ -0,0 +1,233 @@ +# 라이선스 및 결제 준비 문서 + +이 문서는 pg-erd-cloud를 SaaS/온프레미스 배포 시 최소한의 상용 운영 기준을 정리합니다. + +## 1) 상용 배포 모드 + +현재 서비스는 다음 두 가지 운영 모드를 지원합니다. + +- `LICENSE_MODE=off` + - 라이선스 키 검사 미적용 + - 커뮤니티/평가용 기본 동작 +- `LICENSE_MODE=required` + - `/api/projects`, `/api/connections`, `/api/snapshots`, `/api/me`, `/api/auth/*` + 라우트가 `X-LICENSE-KEY` 헤더를 필수로 요구합니다. + - 환경 변수 `LICENSE_KEY` 또는 `LICENSE_PUBLIC_KEY` 중 하나가 필요합니다. + +## 2) 라이선스 키 운영 기본값 + +- 최소 길이 24자 이상 +- 배포마다 랜덤 키를 발급하고 저장소에 커밋하지 않습니다. +- 값은 비밀로 관리하고 정기적으로 교체합니다. +- 노출 또는 유출 의심 시 `LICENSE_KEY`를 즉시 교체합니다. + +## 3) 서명 라이선스 토큰 운영 + +상용 온프레미스 배포는 정적 공유 키보다 `LICENSE_PUBLIC_KEY`를 권장합니다. + +- 운영자는 배포 런타임과 분리된 안전한 환경에서 키와 토큰을 발급합니다. + - `cd backend && python -m app.license_tokens generate-keypair --format env` + - `LICENSE_PRIVATE_KEY`는 발급 환경의 secret으로만 보관하고 배포 환경에는 넣지 않습니다. + - `LICENSE_PUBLIC_KEY`만 고객 배포 환경에 설정합니다. +- `LICENSE_PUBLIC_KEY`는 Ed25519 public key입니다. PEM 문자열 또는 base64url raw public key + 값을 사용할 수 있습니다. +- 고객에게 전달하는 `X-LICENSE-KEY` 값은 다음 형식의 offline token입니다. + - `v1..` +- 토큰 발급/재발급 예시: + - `cd backend && python -m app.license_tokens issue --private-key "$LICENSE_PRIVATE_KEY" --sub customer-acme --plan enterprise --jti license-2026-07 --exp 2027-07-02 --seats 25` +- signature 입력값은 `v1.` 문자열입니다. +- payload는 최소한 다음 claim을 포함해야 합니다. + - `sub`: 고객/계약 식별자 + - `plan`: 계약 플랜 식별자 + - `exp`: Unix epoch seconds 만료 시각 +- 선택 claim: + - `jti`: 라이선스 토큰 식별자. 토큰 단위 회수와 재발급 추적에 사용합니다. + - `nbf`: Unix epoch seconds 활성 시작 시각 + - `seats`: 양의 정수 시트 수 +- `sub`, `plan`, `jti`는 앞뒤 공백이 없는 문자열이어야 합니다. +- 토큰 회수는 운영 환경 변수로 즉시 적용할 수 있습니다. + - `LICENSE_REVOKED_TOKEN_IDS`: 쉼표로 구분한 회수 대상 `jti` + - `LICENSE_REVOKED_SUBJECTS`: 쉼표로 구분한 회수 대상 `sub` +- 회수된 `jti` 또는 `sub`는 만료 전이라도 403으로 거절됩니다. +- 만료, 미활성, 잘못된 payload, 서명 변조는 각각 403으로 거절되며 관측 로그의 + `reason`/`X-Error-Code`에 반영됩니다. + +## 4) 결제/과금 연계 전제 + +- 현재 단계는 상용화 준비 상태이며, 외부 결제 provider별 fulfillment SDK는 아직 + 제품 내부에 고정하지 않습니다. 대신 공통 usage 조회, checkout/plan-change + handoff, provider-neutral reconciliation event 기록 경로를 제공합니다. +- `GET /api/billing/usage`는 현재 사용자 소유 프로젝트 범위의 과금 준비용 사용량을 + 반환합니다. + - `project_count` + - `seat_count` + - `connection_count` + - `snapshot_count` + - `share_link_count` + - `active_share_link_count` + - `license_mode`, `license_verifier` + - `project_limit`, `connection_limit`, `snapshot_limit`, `share_link_limit` + - `account_status` + - `billing_portal_url`, `billing_support_url`, `account_reactivation_url` +- live LLM draft 비용 폭주는 `LLM_DRAFT_QUOTA_ENABLED`, + `LLM_DRAFT_QUOTA_REQUESTS`, `LLM_DRAFT_QUOTA_WINDOW_SECONDS`로 provider 호출 전 + 차단합니다. +- live LLM draft는 `llm_draft_usage_event`에 저장되며, + `GET /api/billing/llm-usage?month=YYYY-MM`가 account 단위 월간 요청 수, + 성공/실패/quota 초과, input/output 문자량을 반환합니다. 이 값은 provider invoice + 대조용 attribution 근거이며, 실제 invoice 정산 자체를 대체하지 않습니다. +- `POST /api/billing/checkout`은 `{ "target_plan": "enterprise-plus" }` + 요청을 받아 결제 시작 경로를 반환합니다. + - `BILLING_CHECKOUT_URL`이 있으면 `target_plan` query를 붙인 checkout redirect + action을 반환합니다. + - checkout URL이 없고 `BILLING_SUPPORT_URL`이 있으면 support contact action을 + 반환합니다. + - 둘 다 없으면 `503`으로 실패해 판매 배포의 구매 시작 경로 누락을 드러냅니다. + - 이 경로는 provider-specific fulfillment 완료, 영수증 처리, seat provisioning, + invoice reconciliation을 대체하지 않습니다. +- `POST /api/billing/plan-change`는 `{ "target_plan": "enterprise-plus" }` + 요청을 받아 plan 변경 실행 경로를 반환합니다. + - `BILLING_PORTAL_URL`이 있으면 `target_plan` query를 붙인 portal redirect + action을 반환합니다. + - portal이 없고 `BILLING_SUPPORT_URL`이 있으면 support contact action을 반환합니다. + - 둘 다 없으면 `503`으로 실패해 판매 배포의 과금 경로 누락을 드러냅니다. + - 사용자 subject 같은 식별자는 portal URL에 자동으로 넣지 않습니다. 고객 매핑은 + 결제 포털 또는 계약 시스템에서 처리해야 합니다. +- `POST /api/billing/events`는 외부 결제/계약 시스템이 보낸 provider-neutral event를 + 저장해 지원/정산 reconciliation 증거로 남깁니다. + - `X-BILLING-WEBHOOK-SECRET` 헤더가 `BILLING_WEBHOOK_SECRET`과 일치해야 합니다. + - `BILLING_WEBHOOK_SIGNATURE_SECRET`을 설정하면 raw request body의 + HMAC-SHA256 값도 `X-BILLING-WEBHOOK-SIGNATURE` 헤더로 검증합니다. + 헤더 값은 `sha256=` 또는 `` 형식을 허용합니다. + - `BILLING_WEBHOOK_SECRET`과 `BILLING_WEBHOOK_SIGNATURE_SECRET`을 모두 설정한 + 배포에서는 두 검증을 모두 통과해야 합니다. + - `provider`와 `provider_event_id` 조합은 한 번만 기록됩니다. 동일 event가 다시 + 오면 duplicate로 응답하고 상태를 두 번 적용하지 않습니다. + - payload는 `provider`, `provider_event_id`, `event_type`, `subject`, + `target_plan`, `occurred_at`, `metadata`를 받을 수 있습니다. + - `BILLING_CONTRACT_STATE_EVENTS_ENABLED=true`이면 normalized `event_type`이 + `BILLING_CONTRACT_DEACTIVATED_EVENT_TYPES`에 포함될 때 해당 subject의 API + 접근을 `account deactivated`로 차단합니다. 이후 최신 contract-state event가 + `BILLING_CONTRACT_ACTIVE_EVENT_TYPES`에 포함되면 차단을 해제합니다. + - 이 기능은 provider-specific checkout/fulfillment SDK가 아닙니다. Stripe, + Paddle, 수기 계약 시스템 등 외부 provider의 원본 event는 gateway에서 + `contract.suspended`, `contract.reactivated` 같은 normalized event_type으로 + 변환한 뒤 전송하거나, `BILLING_EVENT_TYPE_ALIASES` 운영값으로 서버 저장 전에 + 정규화합니다. + - `metadata`의 `secret`, `token`, `password`, `api_key`, `client_secret`, + `authorization`, `card`, `dsn` 계열 키는 저장 전에 `[redacted]`로 치환됩니다. + - 응답에는 민감 metadata를 반환하지 않습니다. +- `BILLING_ENTITLEMENT_EVENT_TYPES`에 포함된 최신 billing event가 `target_plan`을 + 가지면 support diagnostics는 이를 현재 entitlement evidence로 계산하고, 계약 + seat count가 있으면 프로젝트 멤버 초대 시 신규 seat 초과를 차단합니다. + - 기본값은 `checkout.completed`, `invoice.paid`, `subscription.created`, + `subscription.updated`, `contract.activated`, `contract.reactivated`입니다. + - event metadata의 `seat_count`, `seats`, `seat_limit`, `licensed_seats`, + `quantity` 값이 양의 정수이면 contracted seat count로 표시합니다. + - support diagnostics는 contracted seat count와 실제 active seat count를 비교해 + `billing_seat_reconciliation` 상태를 반환합니다. 계약 좌석 수를 초과하면 + 초과 좌석 수와 프로젝트 수가 적은 deprovisioning 후보 subject를 read-only로 + 표시해 수동 검토와 고객 승인 절차를 지원합니다. + - 이 계산은 지원/정산 증거, 기본 seat-limit enforcement, 감사 가능한 + deprovisioning 검토용입니다. provider fulfillment 완료, 자동 계정 제거, + invoice settlement의 원장 시스템을 대체하지 않습니다. +- `GET /api/billing/support/account?subject=`는 + `SUPPORT_OPERATOR_SUBJECTS`에 포함된 사용자만 접근할 수 있는 read-only 지원 + 진단 API입니다. + - 대상 계정 UUID, 활성/비활성/미확인 상태, usage counter, license verifier, + billing/reactivation URL, 현재 entitlement evidence, seat reconciliation, + 현재 월 LLM draft usage summary, 최근 share link summary, 최근 billing event + summary를 반환합니다. + - LLM usage summary는 account 단위 월, 요청 수, 성공/실패/quota 초과, input/output + 문자량만 포함하며 prompt, snapshot JSON, provider response는 반환하지 않습니다. + - share link summary는 UUID, project UUID, 권한, 활성/만료 상태, 생성/만료 + 시각만 포함하며 공개 URL/token은 반환하지 않습니다. + - billing event의 raw metadata는 반환하지 않습니다. + - allowlist에 없는 사용자는 `403 support operator role required`로 거절됩니다. +- `/api/me`는 현재 사용자가 `SUPPORT_OPERATOR_SUBJECTS`에 포함되어 있으면 + `support_operator: true`를 반환합니다. 프론트엔드는 이 값으로 `지원 진단` + 화면 노출 여부를 결정하지만, 실제 접근 제어는 항상 backend support API의 + allowlist 검증을 따릅니다. +- 유료 플랜 한도는 환경 변수로 적용합니다. 값이 `0`이면 해당 항목은 무제한입니다. + - `BILLING_MAX_PROJECTS_PER_USER` + - `BILLING_MAX_CONNECTIONS_PER_PROJECT` + - `BILLING_MAX_SNAPSHOTS_PER_PROJECT` + - `BILLING_MAX_SHARE_LINKS_PER_PROJECT` +- 결제 시작, 고객 포털 또는 지원 경로는 환경 변수로 노출합니다. + - `BILLING_CHECKOUT_URL`: 신규 구매 또는 유료 전환 checkout 시작 URL + - `BILLING_PORTAL_URL`: 플랜 변경, 결제수단, 청구 내역 관리 포털 + - `BILLING_SUPPORT_URL`: 결제/계약 문의 지원 경로 + - `BILLING_WEBHOOK_SECRET`: provider-neutral billing event 기록용 shared secret + - `BILLING_WEBHOOK_SIGNATURE_SECRET`: provider/gateway webhook raw-body + HMAC-SHA256 signature 검증용 secret + - `BILLING_ALLOWED_PLANS`: plan-change 요청과 billing webhook `target_plan`을 + provider/customer catalog와 대조하는 쉼표 구분 plan ID 목록. 비어 있으면 + 호환성을 위해 catalog 검증을 비활성화합니다. + - `BILLING_EVENT_TYPE_ALIASES`: provider 원본 event_type을 내부 normalized + event_type으로 바꾸는 쉼표 구분 `source=target` 목록. 공급자 충돌을 피하려면 + `stripe:customer.subscription.deleted=contract.suspended`처럼 + `provider:event_type=normalized_event_type` 형식을 사용합니다. + - `BILLING_ENTITLEMENT_EVENT_TYPES`: support diagnostics와 member invite + seat-limit enforcement가 현재 plan/seat entitlement evidence로 해석할 billing + event_type 목록 + - `BILLING_CONTRACT_STATE_EVENTS_ENABLED`: billing event에서 account + deactivation/reactivation 상태를 적용할지 여부 + - `BILLING_CONTRACT_DEACTIVATED_EVENT_TYPES`: 접근 차단으로 해석할 normalized + event_type 목록 + - `BILLING_CONTRACT_ACTIVE_EVENT_TYPES`: 접근 허용으로 복귀시키는 normalized + event_type 목록 + - `ACCOUNT_REACTIVATION_URL`: 미납/계약 중단/abuse hold 해제 요청 경로 + - `SUPPORT_OPERATOR_SUBJECTS`: read-only 지원 진단 API 접근 허용 subject 목록 +- 계약 중단, 미납, abuse 대응으로 계정 접근을 즉시 차단해야 하면 + `ACCOUNT_DEACTIVATED_SUBJECTS`에 OIDC subject를 쉼표로 구분해 설정합니다. + 해당 subject는 DB 사용자 upsert 전에 403으로 거절됩니다. 응답에는 + `X-Account-Status: deactivated`와, 설정된 경우 `X-Account-Reactivation-Url`, + `X-Billing-Support-Url` 헤더가 포함됩니다. +- `APP_ENV=production`에서 `ACCOUNT_DEACTIVATED_SUBJECTS`를 설정하려면 + `ACCOUNT_REACTIVATION_URL` 또는 `BILLING_SUPPORT_URL` 중 하나가 필요합니다. +- `BILLING_EVENT_TYPE_ALIASES`가 적용되면 저장되는 `event_type`은 normalized + 값이 되며, 원본 provider event_type은 billing metadata의 `raw_event_type`에 + 감사용으로 보존됩니다. Provider가 같은 key를 metadata로 보낸 경우에도 서버가 + 실제 원본 event_type으로 덮어씁니다. +- `BILLING_ENTITLEMENT_EVENT_TYPES`는 production startup guard에서 billing + event_type ID 형식으로 검증됩니다. +- `BILLING_ALLOWED_PLANS`가 설정된 경우 `POST /api/billing/checkout`, + `POST /api/billing/plan-change`, `POST /api/billing/events`의 `target_plan`이 + catalog에 없으면 `422 target plan is not in configured billing catalog`로 + 거절합니다. Billing webhook 거절은 + `billing_events_total{outcome="rejected_catalog"}`로 기록됩니다. +- 판매 후보 릴리즈는 `docs/operations/billing-provider-catalog.example.json` 형식의 + billing provider catalog manifest를 실제 provider/customer catalog 값으로 복사해 + 유지해야 합니다. `python scripts/ci/validate_billing_provider_catalog.py`는 checkout, + portal, support URL, allowed plan, entitlement event, contract-state event, webhook + secret storage reference가 빠졌는지 검증합니다. +- 승인된 catalog가 실제 배포 env에 적용됐는지는 runtime evidence로 별도 검증합니다. + `python scripts/operations/build_billing_runtime_evidence.py --catalog evidence/billing-provider-catalog.customer.json --env-file .env.production --output evidence/billing-runtime-evidence.json` + 실행 후 + `python scripts/ci/validate_billing_runtime_evidence.py evidence/billing-runtime-evidence.json` + 로 검사합니다. 이 증거는 raw env 값을 저장하지 않고 SHA-256 비교 결과만 남기며, + webhook secret 계열 값이 secret storage reference가 아니면 sale-ready runtime + evidence로 인정하지 않습니다. +- 운영 전에는 다음 항목을 추가해야 합니다. + - 계약 단위 플랜(월 구독/온프레미스 라이선스) 매핑 + - 청구 주기, 미납 정책, 계정 비활성 규칙 + - 팀별 시트/계정 할당량(사용자 수, API 호출량) 정책 + - provider별 fulfillment 어댑터, customer portal 심화 연동, 실제 event catalog에 + 맞춘 별칭, entitlement event type, plan catalog 운영값, 자동 seat provisioning + +## 5) 온프레미스 체크리스트 + +- `LICENSE_MODE=required`를 활성화하면 비인가 배포/임시 실행을 줄일 수 있습니다. +- 실제 영업용 패키지는 현재 CLI 기반 발급/재발급, 배포 환경 변수 기반 회수 목록, + 기본 사용량 한도, OIDC subject 기반 계정 비활성화를 사용할 수 있습니다. 다만 고객 + 포털 기반 발급·회수·재발급 자동화와 자동 재활성화 정책은 별도 운영 시스템이 + 필요합니다. 정적 `LICENSE_KEY`는 기존 배포 호환용으로만 유지합니다. +- 상업 전환 시에는 계정 포털(키 발급/회수/재발급/로그 감사)과 연동하세요. + +## 6) 배포 체크포인트 + +- `LICENSE_MODE`와 `LICENSE_KEY` 또는 `LICENSE_PUBLIC_KEY`를 배포값으로 분리 +- `LICENSE_REVOKED_TOKEN_IDS`, `LICENSE_REVOKED_SUBJECTS`를 비상 회수 절차에 포함 +- 환경 변수 바인딩/문서화 +- 운영 키 저장소(시크릿 저장소)에서만 관리 +- 정적 키 노출 또는 서명 토큰 오남용 탐지 시 비상 대응(runbook) 연동 diff --git a/docs/legal/privacy-policy.md b/docs/legal/privacy-policy.md new file mode 100644 index 00000000..1a8d3217 --- /dev/null +++ b/docs/legal/privacy-policy.md @@ -0,0 +1,31 @@ +# 개인정보 처리 안내(준비본) + +본 문서는 판매용 배포 전 점검용 템플릿입니다. + +## 수집하는 정보 + +- 사용자 인증 토큰/세션(브라우저/클라이언트 저장) +- 프로젝트 메타데이터 및 사용자 계정 식별자(내부 식별자/OIDC subject) +- 연결 정보: 암호화된 DB 접속 DSN 및 메타데이터 +- 감사 로그: `event`, `action`, `share_link_uuid`, `project_space_uuid` 등 +- 사용량/성능 이벤트(요청 경로, 상태 코드, 제한 초과 이벤트) + +## 보관 및 보호 + +- 연결 DSN은 앱 내부 `encrypted_blob` 테이블에 저장되며 DB 암호화 키로 보호됩니다. +- 공유 링크는 TTL이 적용되며 기본 만료는 기본 7일입니다. +- 운영 환경에서는 TLS/HTTPS, 인증/인가, 감사 로그, API Rate Limit를 함께 운영해야 합니다. + +## 제3자 처리 + +- 현재 버전은 SaaS 결제 대행/CRM 연동 등 제3자 처리 파이프라인이 내장되어 있지 않습니다. +- 상용 패키지에는 계약한 결제/분석/고객지원 도구 연계를 추가해 데이터 범위를 명확히 해야 합니다. + +## 데이터 삭제 및 권리 + +- 사용자 및 조직은 운영 계약 조건에 따라 계정/프로젝트/스냅샷 제거 절차를 요청할 수 있습니다. +- 데이터 삭제 요청 대응은 운영 runbook에 명시된 보존 기간 정책을 따릅니다. + +## 영업/배포 전 체크 + +- 이 문서는 법률 자문용 템플릿이므로 각 국가/지역의 규제 요건에 맞게 수정해야 합니다. diff --git a/docs/legal/release-approvals/release-approval.example.json b/docs/legal/release-approvals/release-approval.example.json new file mode 100644 index 00000000..3a710223 --- /dev/null +++ b/docs/legal/release-approvals/release-approval.example.json @@ -0,0 +1,52 @@ +{ + "release_version": "v2026.07.02-commercial-candidate", + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "sale_motion": "hybrid", + "contract_plan": "enterprise", + "billing_method": "external_contract_invoice", + "approval_date": "2026-07-02", + "product_owner": "Product Owner product@example.com", + "legal_approver": "Legal Approver legal@example.com", + "security_approver": "Security Approver security@example.com", + "support_approver": "Support Approver support@example.com", + "support_channel": "contract-support@example.com", + "vulnerability_owner": "security@example.com", + "documents": { + "terms": "docs/legal/terms-of-service.md", + "privacy": "docs/legal/privacy-policy.md", + "sla": "docs/legal/sla-support.md", + "license_billing": "docs/legal/license-billing.md", + "security_policy": "SECURITY.md", + "incident_response": "docs/operations/incident-response.md", + "backup_restore": "docs/operations/backup-restore.md", + "migration_rollback": "docs/operations/migration-rollback.md", + "on_premises_package": "docs/operations/on-premises-package.md", + "alert_thresholds": "docs/operations/alert-thresholds.md", + "billing_provider_catalog": "docs/operations/billing-provider-catalog.example.json", + "support_bundle_generator": "scripts/operations/generate_support_bundle.py", + "support_bundle_example": "docs/operations/support-bundles/support-bundle.example.json", + "commercial_readiness_audit": "scripts/ci/commercial_readiness_audit.py" + }, + "data_processing_scope": [ + "application metadata", + "encrypted connection DSN", + "schema snapshot JSON", + "share-link audit log", + "support diagnostic log" + ], + "subprocessors": [ + "hosting provider", + "billing provider", + "support ticket system" + ], + "unsupported_scope": [ + "unapproved third-party LLM provider processing", + "non-contract regions", + "customer-managed database writes" + ], + "customer_notice": [ + "Public share links expire by default.", + "Signed license tokens can be revoked before expiry.", + "Account reactivation follows the configured billing/support URL." + ] +} diff --git a/docs/legal/sla-support.md b/docs/legal/sla-support.md new file mode 100644 index 00000000..b02eb0d0 --- /dev/null +++ b/docs/legal/sla-support.md @@ -0,0 +1,35 @@ +# SLA / 지원 운영 기준 + +본 문서는 상용 출시 전 지원 운영 기준입니다. 계약별 응답 시간, 크레딧, +위약/면책 문구는 `commercial-release-approval.md`의 승인 기록에 연결합니다. + +## 가용성 + +- 기본 목표: 평일 업무 시간 내 장애 대응 체계를 갖춘 운영 +- 24x7 무중단 SLO를 제공하려면 모니터링, 알림 채널, 장애 대응 교대조 체계를 별도 운영해야 합니다. + +## 지원 채널 + +- 1차: 저장소 이슈 트래커 또는 계약 채널 +- 긴급: 내부 핫라인(운영 runbook 연동) +- 응답 기준: 계약 등급별로 분류 +- 보안 취약점: `SECURITY.md`의 private advisory 경로 사용 + +## 장애 대응 + +- 알림: `rate limit`, `share_audit`, `LLM provider` 실패, 인증 실패 급증 +- 대응 문서: `docs/operations/incident-response.md` +- 백업/복구: `docs/operations/backup-restore.md` +- 마이그레이션 롤백: `docs/operations/migration-rollback.md` + +## 데이터 보전 + +- 규정된 보존 기간 이후 로그/스냅샷 정리 정책 +- 백업 복구 연습은 월 1회 이상 권장 + +## 판매 전 승인 조건 + +- 계약 등급별 응답 목표와 제외 범위를 계약서에 반영 +- 지원 채널, 담당자, 긴급 연락 경로 확정 +- 장애 통지 기준과 고객 공지 템플릿 확정 +- 백업/복구 및 마이그레이션 롤백 runbook 리허설 완료 diff --git a/docs/legal/terms-of-service.md b/docs/legal/terms-of-service.md new file mode 100644 index 00000000..67ff55f3 --- /dev/null +++ b/docs/legal/terms-of-service.md @@ -0,0 +1,26 @@ +# 이용약관(초안) + +> 본 문서는 영업 전 내부 준비용 초안입니다. 실제 법률 검토/제휴 계약 문구로 교체해야 합니다. + +## 1) 서비스 범위 + +- 사용자에게 제공되는 기능: PostgreSQL/Snowflake 스키마 리버스 엔지니어링, + ERD/DDL 공유, 스냅샷 내보내기, LLM 보조 문서화(옵션). +- 서비스는 연동 환경의 데이터 처리와 제3자 라이선스 규정을 사용자 책임 하에 둡니다. + +## 2) 계정 및 라이선스 + +- 라이선스 모드(`LICENSE_MODE`)를 켜면 `X-LICENSE-KEY` 헤더 기반 키 관리가 적용됩니다. +- 키를 임의로 공유하거나 제3자에 노출해서는 안 됩니다. +- 유효하지 않거나 만료된 키는 서비스 접근을 제한합니다. + +## 3) 사용 제한 + +- API misuse 방지 및 비용 통제 정책이 적용됩니다(요청 제한, 공유 링크의 공개 LLM 비용 통제, 감사로그). +- 운영 정책 위반, 악의적 남용, 규정 위반이 확인되면 접근이 제한될 수 있습니다. + +## 4) 면책 및 책임 + +- 데이터 손실, 인식 실패, 제3자 서비스(LLM/OIDC/운영 인프라) 장애로 인한 간접 손해에 대한 + 구체 조항은 계약 체결 시 별도 고지합니다. +- 상용 배포 전 고지문(보증/면책/서비스 중단/책임 한도)은 별도 법률 자문으로 보완해야 합니다. diff --git a/docs/observability.md b/docs/observability.md index ccf97c81..152733c9 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -1,10 +1,10 @@ -# Observability (MVP) +# Observability Baseline This document defines the **minimum observability baseline** for `pg-erd-cloud`: - Central **structured logs** (JSON) - Basic **metrics** (Prometheus exposition) -- Suggested **alert thresholds** +- Deployable **alert thresholds** Scope is intentionally small (issue #49) and can be expanded once the runtime stack (Kubernetes/VM, managed monitoring, etc.) is finalized. @@ -50,6 +50,39 @@ When enabled, the backend exposes metrics at: - `http_requests_total{method,route,status}` - `http_request_duration_seconds_bucket|sum|count{method,route}` +- `authz_failures_total{status,route,reason}` +- `share_audit_events_total{action,outcome}` +- `billing_events_total{provider,event_type,outcome}` +- `llm_draft_requests_total{surface,artifact,outcome}` +- `llm_draft_input_chars_bucket|sum|count{surface,artifact}` +- `llm_draft_output_chars_bucket|sum|count{surface,artifact,outcome}` +- `product_events_total{area,action,outcome}` + +### LLM usage logs + +Live LLM draft paths also emit `event=llm_draft_usage` structured logs. These +logs include `surface`, `artifact`, `outcome`, UUID references, and input/output +character counts. They intentionally do not include prompt contents, snapshot +JSON, provider responses, or API keys. + +The same live draft events are persisted to `llm_draft_usage_event` for monthly +billing attribution. Authenticated users can query +`GET /api/billing/llm-usage?month=YYYY-MM` to retrieve account-level request, +success, failure, quota-exceeded, input-character, and output-character totals. +Authorized support operators see the current-month account summary in +`GET /api/billing/support/account?subject=` and the support +diagnostics UI. Use those totals to reconcile provider invoices; do not log or +store prompt contents for this workflow. + +`outcome=quota_exceeded` means the in-process LLM draft quota rejected the +request before any provider call. Authenticated routes use the account UUID as +the quota key; shared routes use the share-link UUID. `Retry-After` is returned +to the caller with the 429 response. + +Shared live LLM draft exports also emit `event=share_audit` with +`outcome=success` or `outcome=denied`, so support can correlate a public export +or quota denial with the share link, project, snapshot, and request ID without +seeing prompt contents. ### Job queue metrics (DB-backed queue) @@ -60,24 +93,46 @@ When enabled, the backend exposes metrics at: - `job_queue_processing_seconds_bucket|sum|count{job_type,outcome}` - handler runtime -## 3) Alerting (suggested baseline) +## 3) Alerting baseline -Tune thresholds per environment; these are reasonable starting points. +The deployable Prometheus rule file is: + +- `deploy/prometheus/pg-erd-cloud-alerts.yml` + +Tune thresholds per environment; the checked-in defaults are the commercial +readiness baseline. The operational interpretation, owner, escalation rule, and +release approval checklist are fixed in +`docs/operations/alert-thresholds.md`. ### API - 5xx error spike - - alert when `5xx / total` > 1% for 5 minutes + - `PgErdCloudHigh5xxRate`: alert when `5xx / total` > 1% for 5 minutes - Latency regression - - alert when p95 latency > 1s for 10 minutes (per route group) + - `PgErdCloudHighRouteLatency`: alert when p95 latency > 1s for 10 minutes + (per route group) +- 인증/인가 실패 급증 + - `PgErdCloudAuthzFailureSpike`: alert when + `sum by (route,reason) (rate(authz_failures_total[5m]))` > 10/s +- 공유 감사 실패/비정상 + - `PgErdCloudShareAbuseOrFailureSpike`: alert when + `sum by (action,outcome) (rate(share_audit_events_total{outcome=~"denied|failed"}[5m]))` + > 5/s +- LLM draft 실패 + - `PgErdCloudLlmDraftFailures`: alert when live LLM draft configuration, + prompt-size, or provider failures occur in the last 10 minutes. +- 결제 webhook/catalog 실패 + - `PgErdCloudBillingWebhookFailures`: alert when billing webhook auth, + configuration, or configured plan catalog validation fails. ### Job queue - Failure spike - - alert when `job_queue_jobs_total{outcome="failed"}` increases above baseline - or failure rate > 5% for 10 minutes + - `PgErdCloudJobFailures`: alert when + `increase(job_queue_jobs_total{outcome="failed"}[10m])` > 0 - Backlog / wait time - - alert when p95 `job_queue_wait_seconds` > 60s for 10 minutes + - `PgErdCloudJobQueueWaitHigh`: alert when p95 `job_queue_wait_seconds` > + 60s for 10 minutes ## 4) Validation (local) diff --git a/docs/operations/alert-thresholds.md b/docs/operations/alert-thresholds.md new file mode 100644 index 00000000..32b4f48d --- /dev/null +++ b/docs/operations/alert-thresholds.md @@ -0,0 +1,36 @@ +# Commercial Alert Thresholds + +이 문서는 `deploy/prometheus/pg-erd-cloud-alerts.yml`의 기본 alert rule을 +상용 SaaS/온프레미스 운영 gate로 해석하는 기준입니다. 환경별 수치 조정은 가능하지만, +severity, 고객 영향 판단, 1차 조치, escalation owner는 릴리즈 승인 기록에 남겨야 +합니다. + +## Threshold Matrix + +| Alert | Severity | Commercial threshold | Customer impact | First response | Owner | +|---|---|---|---|---|---| +| `PgErdCloudHigh5xxRate` | `page` | 5분 동안 전체 요청 중 5xx 비율 > 1% | API 생성/조회/공유 흐름 실패 가능 | 최근 배포, DB 연결, upstream 오류율 확인 후 incident commander 지정 | On-call engineer | +| `PgErdCloudHighRouteLatency` | `ticket` | 10분 동안 route별 p95 > 1s | ERD 조회, 공유 링크, 관리 화면 지연 | route label별 상위 지연 원인과 DB/queue 대기 확인 | Backend owner | +| `PgErdCloudAuthzFailureSpike` | `ticket` | route/reason별 5분 rate > 10/s | 인증 설정 drift, abuse, 고객 SSO 장애 가능 | OIDC issuer/audience drift, deactivated subject, rate limit 로그 확인 | Security owner | +| `PgErdCloudShareAbuseOrFailureSpike` | `ticket` | action/outcome별 denied/failed 5분 rate > 5/s | 공개 공유 링크 남용 또는 고객 공유 실패 | share audit 로그, request_id, link TTL/revocation 상태 확인 | Support + Security | +| `PgErdCloudBillingWebhookFailures` | `ticket` | 10분 동안 billing webhook rejected_auth/rejected_config/rejected_catalog > 0 | 결제/계약 event 반영 실패, 미납/플랜 변경 상태 불일치 가능 | provider/gateway secret, raw-body signature, `BILLING_ALLOWED_PLANS`, event replay, support diagnostics 확인 | Billing owner | +| `PgErdCloudLlmDraftFailures` | `ticket` | 10분 동안 LLM draft configuration/provider/prompt-size/quota 실패 > 0 | LLM 보조 산출물 생성 실패 또는 제3자 provider 비용/연동 문제 가능 | `event=llm_draft_usage`, provider key, prompt size, `SHARE_LINK_LLM_DRAFT_ENABLED`, `LLM_DRAFT_QUOTA_*` 상태 확인 | Product + Backend owner | +| `PgErdCloudJobFailures` | `ticket` | 10분 동안 background job 실패 > 0 | 스냅샷 생성/역공학 작업 실패 | 실패 job type, DSN allowlist, target DB reachability 확인 | Backend owner | +| `PgErdCloudJobQueueWaitHigh` | `ticket` | 10분 동안 job type별 p95 wait > 60s | 스냅샷 생성 대기 증가 | worker 수, DB lock, queue backlog, run_after drift 확인 | Operations owner | + +## Escalation Rules + +- `page` alert는 15분 안에 담당자를 지정하고 incident timeline을 시작합니다. +- 동일 고객 또는 동일 route에서 `ticket` alert가 30분 이상 지속되면 `page`로 승격합니다. +- authz/share abuse alert는 고객 영향이 없어도 보안 triage 기록을 남깁니다. +- LLM provider 또는 billing provider 장애가 연관되면 고객 공지 필요 여부를 + `docs/legal/release-approvals/*.json`의 `customer_notice` 정책과 비교합니다. + +## Release Approval Checklist + +상용 릴리즈 승인자는 다음을 확인합니다. + +- Prometheus rule 파일이 배포 대상 환경에 로드되어 있습니다. +- `/metrics`는 내부망 또는 token-protected endpoint로 제한되어 있습니다. +- severity별 담당자와 escalation channel이 계약 지원 문서와 일치합니다. +- threshold를 환경별로 변경한 경우 변경 사유, 새 수치, 승인자를 manifest에 기록했습니다. diff --git a/docs/operations/backup-restore.md b/docs/operations/backup-restore.md new file mode 100644 index 00000000..140bdb8f --- /dev/null +++ b/docs/operations/backup-restore.md @@ -0,0 +1,141 @@ +# Backup and Restore Runbook + +이 runbook은 `pg-erd-cloud`가 저장하는 앱 메타데이터 PostgreSQL을 대상으로 합니다. +대상 고객 DB의 원본 데이터는 저장하지 않지만, 프로젝트, 멤버십, 암호화된 DSN, +스키마 스냅샷, 공유 링크, job queue 상태는 앱 DB에 저장됩니다. + +## Commercial Baseline + +- RPO: 최대 24시간 데이터 손실을 허용하는 일일 logical backup을 기본 기준으로 합니다. +- RTO: 단일 인스턴스 장애 시 4시간 내 복구 리허설이 가능한 절차를 유지합니다. +- 보관: 최소 7일 daily, 4주 weekly 보관을 권장합니다. +- 검증: 백업 파일 생성만으로 완료하지 않고, 격리 DB에 restore 후 `/healthz`와 핵심 API + smoke test를 통과해야 합니다. +- secret: `APP_SECRET` 또는 `APP_SECRET_FILE`은 DB 백업과 분리 보관합니다. 이 값이 + 유실되면 저장된 DSN ciphertext를 복호화할 수 없습니다. + +## Backup + +운영 DB 컨테이너 이름이 compose 기본값인 `erd_postgres`일 때: + +```bash +timestamp="$(date -u +%Y%m%dT%H%M%SZ)" +mkdir -p backups + +docker exec erd_postgres \ + pg_dump \ + --username "${POSTGRES_USER:-erd}" \ + --dbname "${POSTGRES_DB:-erd}" \ + --format custom \ + --no-owner \ + --file "/tmp/pg-erd-cloud-${timestamp}.dump" + +docker cp "erd_postgres:/tmp/pg-erd-cloud-${timestamp}.dump" \ + "backups/pg-erd-cloud-${timestamp}.dump" + +docker exec erd_postgres rm -f "/tmp/pg-erd-cloud-${timestamp}.dump" +sha256sum "backups/pg-erd-cloud-${timestamp}.dump" \ + > "backups/pg-erd-cloud-${timestamp}.dump.sha256" +``` + +## Restore Drill + +복구 검증은 운영 DB에 직접 수행하지 않습니다. 격리된 PostgreSQL에 restore합니다. + +```bash +dump_file="backups/pg-erd-cloud-YYYYMMDDTHHMMSSZ.dump" + +docker run --rm --name pg-erd-restore-drill \ + -e POSTGRES_DB=erd_restore \ + -e POSTGRES_USER=erd \ + -e POSTGRES_PASSWORD=restore-test-password \ + -p 127.0.0.1:55432:5432 \ + -d postgres:16-alpine + +until docker exec pg-erd-restore-drill \ + pg_isready -U erd -d erd_restore -h 127.0.0.1; do + sleep 1 +done + +docker cp "$dump_file" pg-erd-restore-drill:/tmp/restore.dump +docker exec pg-erd-restore-drill \ + pg_restore \ + --username erd \ + --dbname erd_restore \ + --clean \ + --if-exists \ + --no-owner \ + /tmp/restore.dump +``` + +검증 후 정리: + +```bash +docker rm -f pg-erd-restore-drill +``` + +## Application Smoke Test After Restore + +복구 DB를 임시 backend에 연결할 때는 운영 secret과 동일한 `APP_SECRET` 또는 +`APP_SECRET_FILE`을 사용해야 DSN 복호화 경로를 검증할 수 있습니다. + +최소 확인 항목: + +- `/healthz`가 `{"ok": true}`를 반환합니다. +- `alembic current`가 배포 revision과 일치합니다. +- 프로젝트 목록 API가 인증된 사용자에게 정상 응답합니다. +- 기존 공유 링크가 만료 정책과 삭제 API에 맞게 동작합니다. +- 최근 성공 스냅샷의 SQL export가 200 응답을 반환합니다. + +## Restore Drill Evidence Manifest + +복구 drill 결과는 `docs/operations/restore-drills/restore-drill.example.json` 형식의 +JSON manifest로 남깁니다. 실제 유료 파일럿이나 고객 staging drill에서는 example을 +복사해 날짜, commit, backup artifact, restore target, smoke test 결과, 증거 링크를 +실제 값으로 바꿉니다. + +필수 evidence: + +- backup artifact 경로, 64자 SHA-256, 생성 시각 +- digest로 고정된 `postgres:16-alpine` restore target +- `APP_SECRET` 원문이 아닌 secret store 또는 file URI +- `alembic current`와 기대 revision 일치 +- `/healthz`, 프로젝트 목록, 공유 링크 조회/폐기 또는 만료, SQL export, + support bundle redaction smoke 결과 +- backup, restore, application smoke 소요 시간 + +Support bundle redaction smoke는 복구 DB와 동일한 배포 증거에서 +`scripts/operations/generate_support_bundle.py`를 실행해 확인합니다. 이 smoke는 +bundle 생성이 성공하고 결과 JSON에 raw `APP_SECRET`, DB password, DSN, private key, +billing secret, raw provider metadata, 공개 share URL/token이 남지 않는지를 확인한 +뒤 `smoke_tests.support_bundle_redaction=true`로 기록합니다. + +검증: + +```bash +python scripts/operations/generate_support_bundle.py \ + --commit-sha "$(git rev-parse HEAD)" \ + --billing-provider-catalog-version "catalog-2026-07-02" \ + --healthz-file evidence/healthz.json \ + --support-account-file evidence/support-account.json \ + --backend-log-file evidence/backend-error.log \ + --output evidence/support-bundle.json +python scripts/ci/validate_support_bundle.py evidence/support-bundle.json +python scripts/ci/validate_restore_drill_manifest.py evidence/restore-drill.customer.json +``` + +## Failure Handling + +- restore가 실패하면 해당 dump는 폐기하고 직전 백업으로 재시도합니다. +- `APP_SECRET` 유실 또는 불일치로 DSN 복호화가 실패하면 DB restore만으로 완전 복구가 + 불가능합니다. secret 보관소에서 올바른 값을 복구하기 전까지 운영 전환을 중단합니다. +- migration mismatch가 있으면 앱을 올리지 말고, 현재 배포 commit의 alembic revision과 + restore DB의 revision을 먼저 맞춥니다. +- migration rollback이 빈번해질 가능성이 높으면 [migration rollback policy](./migration-rollback.md) + 를 함께 점검하고, 장애 징후 발생 시 [incident response runbook](./incident-response.md)을 병행합니다. + +## Recurring Review + +- 매주 1회 restore drill을 수행하고 결과를 운영 이슈 또는 변경 기록에 남깁니다. +- `compose.prod.yaml`, DB major version, migration 전략, secret 주입 방식이 바뀔 때마다 + 이 runbook을 같은 PR에서 갱신합니다. diff --git a/docs/operations/billing-provider-catalog.example.json b/docs/operations/billing-provider-catalog.example.json new file mode 100644 index 00000000..79c37aa6 --- /dev/null +++ b/docs/operations/billing-provider-catalog.example.json @@ -0,0 +1,78 @@ +{ + "catalog_version": "2026-07-02-enterprise-catalog", + "effective_date": "2026-07-02", + "provider": "stripe", + "checkout_url": "https://billing.example.com/checkout", + "portal_url": "https://billing.example.com/portal", + "support_url": "https://support.example.com/billing", + "webhook_auth": { + "shared_secret_env": "BILLING_WEBHOOK_SECRET", + "signature_secret_env": "BILLING_WEBHOOK_SIGNATURE_SECRET" + }, + "allowed_plans": [ + { + "plan_id": "enterprise", + "display_name": "Enterprise", + "billing_method": "subscription", + "contract_currency": "KRW", + "fulfillment_owner": "Billing Ops billing@example.com", + "entitlement_event_types": [ + "checkout.completed", + "invoice.paid", + "subscription.updated" + ], + "contract_state_active_event_types": [ + "contract.activated", + "contract.reactivated" + ], + "contract_state_deactivated_event_types": [ + "contract.suspended" + ], + "seat_count_required": true, + "support_sla": "business-critical" + }, + { + "plan_id": "onprem-enterprise", + "display_name": "On-premises Enterprise", + "billing_method": "external_contract_invoice", + "contract_currency": "KRW", + "fulfillment_owner": "Contract Ops contracts@example.com", + "entitlement_event_types": [ + "contract.activated", + "contract.reactivated" + ], + "contract_state_active_event_types": [ + "contract.activated", + "contract.reactivated" + ], + "contract_state_deactivated_event_types": [ + "contract.suspended" + ], + "seat_count_required": true, + "support_sla": "business-critical" + } + ], + "event_type_aliases": [ + { + "source": "stripe:customer.subscription.deleted", + "target": "contract.suspended" + }, + { + "source": "stripe:checkout.session.completed", + "target": "checkout.completed" + } + ], + "required_environment": { + "BILLING_ALLOWED_PLANS": "enterprise,onprem-enterprise", + "BILLING_CHECKOUT_URL": "https://billing.example.com/checkout", + "BILLING_PORTAL_URL": "https://billing.example.com/portal", + "BILLING_SUPPORT_URL": "https://support.example.com/billing", + "BILLING_WEBHOOK_SECRET": "secret-manager:pg-erd-cloud/billing-webhook-secret", + "BILLING_WEBHOOK_SIGNATURE_SECRET": "secret-manager:pg-erd-cloud/billing-webhook-signature", + "BILLING_EVENT_TYPE_ALIASES": "stripe:customer.subscription.deleted=contract.suspended,stripe:checkout.session.completed=checkout.completed", + "BILLING_ENTITLEMENT_EVENT_TYPES": "checkout.completed,invoice.paid,subscription.updated,contract.activated,contract.reactivated", + "BILLING_CONTRACT_STATE_EVENTS_ENABLED": "true", + "BILLING_CONTRACT_ACTIVE_EVENT_TYPES": "contract.activated,contract.reactivated", + "BILLING_CONTRACT_DEACTIVATED_EVENT_TYPES": "contract.suspended" + } +} diff --git a/docs/operations/billing-runtime.env.example b/docs/operations/billing-runtime.env.example new file mode 100644 index 00000000..2a015ed7 --- /dev/null +++ b/docs/operations/billing-runtime.env.example @@ -0,0 +1,11 @@ +BILLING_ALLOWED_PLANS=enterprise,onprem-enterprise +BILLING_CHECKOUT_URL=https://billing.example.com/checkout +BILLING_CONTRACT_ACTIVE_EVENT_TYPES=contract.activated,contract.reactivated +BILLING_CONTRACT_DEACTIVATED_EVENT_TYPES=contract.suspended +BILLING_CONTRACT_STATE_EVENTS_ENABLED=true +BILLING_ENTITLEMENT_EVENT_TYPES=checkout.completed,invoice.paid,subscription.updated,contract.activated,contract.reactivated +BILLING_EVENT_TYPE_ALIASES=stripe:customer.subscription.deleted=contract.suspended,stripe:checkout.session.completed=checkout.completed +BILLING_PORTAL_URL=https://billing.example.com/portal +BILLING_SUPPORT_URL=https://support.example.com/billing +BILLING_WEBHOOK_SECRET=secret-manager:pg-erd-cloud/billing-webhook-secret +BILLING_WEBHOOK_SIGNATURE_SECRET=secret-manager:pg-erd-cloud/billing-webhook-signature diff --git a/docs/operations/incident-response.md b/docs/operations/incident-response.md new file mode 100644 index 00000000..44828c14 --- /dev/null +++ b/docs/operations/incident-response.md @@ -0,0 +1,52 @@ +# Incident Response Runbook + +이 문서는 `pg-erd-cloud` 운영 중 긴급 장애가 발생했을 때 1차 대응 순서를 정의합니다. + +## Severity 정의 + +- **S1: 고객 데이터 또는 계정 접근 손실 위험** + - 공개 공유 링크가 대량 유출되거나, 권한 우회가 확인된 경우 + - 공유 링크/프로젝트 데이터 손실 또는 잘못된 데이터 노출이 의심되는 경우 +- **S2: 서비스 이용 장애** + - 백엔드 API 다수 응답 실패(5xx 급증), 중요 엔드포인트 `/healthz` 실패 + - 스냅샷 생성/조회 전반이 중단되는 경우 +- **S3: 성능 및 비용 이상** + - LLM draft 비용 급등 의심, 요청 폭주로 인한 과금/성능 임계치 초과 + +## 5분 진입 체크리스트 (SRE On-call) + +1. 상태 확인 + - `/healthz` 응답, 최근 배포 상태, 알림 채널 알람 수신 내역 확인 + - 앱 로그에서 `event=share_audit`의 `request_id`를 기준으로 `event=authz_failure`, `http_request`와 함께 조회 + - `authz_failures_total` 및 `share_audit_events_total` 메트릭에서 직전 15분 구간의 급증 여부를 우선 점검 +2. 스코프 확인 + - 공개 공유 링크 계열인지, 인증 API인지, LLM draft 경로인지 분기 +3. 피해 억제 + - 긴급 차단이 필요한 경우 `APP_ENV`가 아니더라도 라우팅/방화벽에서 + `mode=llm-draft` 경로를 일시 제한 + - 필요 시 공유 링크 목록 폐기 API를 통해 이상 링크 즉시 폐기 +4. 커뮤니케이션 + - 장애 원인 추정과 임시 완화 조치, 예상 복구 ETA를 내부 채널에 공유 + +## LLM 비용 과다 사용 대응 + +- 첫 단계: `SHARE_LINK_LLM_DRAFT_ENABLED=false` 또는 `app.share` 감시 알람 기준으로 + 공유 링크 draft 경로를 임시 비활성화 +- 두 번째 단계: LLM provider key 회전 및 토큰 예산 제한 재점검 +- 세 번째 단계: 과금 추적 로그를 기준으로 상위 요청자/공유 링크 UUID를 식별 후 + 해당 공유 자격 폐기 + +## 운영 로그 및 근거 보존 + +- 장애 대응 중 다음 항목은 보존합니다. + - 로그: `app.share`, `app.main`, 배포 이벤트, 백엔드 에러 로그 + - 배포/마이그레이션 이력: `git log`, `alembic current` + - 영향 범위: 장애 기간, 사용자 영향, 실패 API 목록, 조치 시간 + +## 복구 후 정리 + +1. `alembic current`/`alembic history`를 통해 스키마 정합성 확인 +2. 핵심 API smoke test(`/healthz`, 스냅샷 조회, 공유 링크 조회/폐기, SQL export) 실행 +3. 장애 대응 절차와 원인, 조치 근거를 운영 로그/이슈에 남김 +4. 같은 유형이 30일 내 반복되면 `docs/commercial-readiness.md` P1 항목 + (알람 정책/한계치) 업데이트 diff --git a/docs/operations/migration-rollback.md b/docs/operations/migration-rollback.md new file mode 100644 index 00000000..f7df1151 --- /dev/null +++ b/docs/operations/migration-rollback.md @@ -0,0 +1,92 @@ +# Migration Rollback Policy + +이 문서는 운영 중 DB 마이그레이션 실패 또는 기능 이상이 탐지되었을 때 +`alembic` 기준 안전하게 롤백하는 절차를 제공합니다. + +## 원칙 + +- **원인 규명이 끝나기 전 임시 조치**: 추가 데이터 손상을 막기 위해 우선 읽기 + 트래픽을 제한하거나 필요한 API 노출을 줄입니다. +- **항상 사전 검증**: 본환경 rollback은 격리 환경에서 사전 검증 후 실행합니다. +- **증거 보존**: `HEAD`, `alembic history`, 배포 커밋 해시를 이벤트 기록에 남깁니다. + +## 실행 기준 + +- `alembic current` 결과가 코드 레벨의 `alembic head`와 다를 때 +- 배포 후 10분 이내 핵심 API가 연속 실패하거나 데이터 무결성 이상이 확인될 때 + +## 사전 점검 + +```bash +cd backend +alembic current +alembic history --verbose | head -n 20 +``` + +현재 데이터베이스 revision을 `HEAD`와 비교합니다. + +## 단계별 롤백 절차 + +### 1) 위험 완화 + +- 웹/공유 공개 라우팅에서 영향 범위를 줄입니다. +- 필요 시 일시적으로 `SHARE_LINK_LLM_DRAFT_ENABLED=false`와 라우트 제한을 강화합니다. + +### 2) 롤백 대상 선정 + +- 실패 원인이 마지막 마이그레이션(예: `abcd1234`)으로 확인되면 `--sql` 모드로 + 문법/결과를 예행연습합니다. + +```bash +cd backend +alembic downgrade --sql abcd1234 -1 +``` + +### 3) 스테이징에서 dry-run 확인 + +- 운영 DB를 직접 건드리지 않고 스테이징 DB에서 동일 절차를 반복합니다. + +### 4) 운영 롤백 실행 + +```bash +docker compose -f compose.prod.yaml run --rm backend alembic downgrade -1 +``` + +필요 시 마지막 2~3개 리비전을 한 번에 되돌리는 경우: + +```bash +docker compose -f compose.prod.yaml run --rm backend alembic downgrade -2 +``` + +### 5) 복구 검증 + +- `alembic current`가 의도한 revision인지 확인 +- `/healthz`와 핵심 API smoke test 실행: + - 프로젝트 조회, 스냅샷 조회, 공유 링크 조회/폐기, SQL export +- 문제가 계속되면 추가 rollback 또는 DB restore drill로 전환합니다. + +## 롤백 불가 사례 + +- 데이터 변형이 영구적인 마이그레이션(대량 데이터 삭제/이관)은 롤백 전에 + 백업 유효성 확인 후에만 진행합니다. +- `APP_SECRET` 변경 이력과 앱 DB 암호화 데이터 무결성 검토 없이 복구를 진행하지 않습니다. + +## 운영 문서 갱신 규칙 + +- 롤백이 실제로 발생하면 24시간 이내에 `docs/commercial-readiness.md`의 P1/P0 항목 + 상태를 갱신하고, 배포 이슈/PR 본문에 재발 방지 조치를 첨부합니다. + +## Rollback Drill Evidence + +운영 투입 전 staging 또는 고객 승인 환경에서 최소 1회 rollback drill을 수행하고 +`docs/operations/rollback-drills/rollback-drill.example.json` 형식의 manifest로 +남깁니다. 실제 판매 증거는 저장소에 커밋하지 않고 다음 명령으로 직접 검증할 수 +있습니다. + +```bash +python scripts/ci/validate_rollback_drill_manifest.py evidence/rollback-drill.customer.json +``` + +manifest에는 rollback 전 backup artifact SHA-256, `alembic downgrade --sql` dry-run +SQL digest, 실제 rollback 명령, rollback 전/대상/후 revision, `/healthz`, 프로젝트, +스냅샷, 공유 링크, SQL export, support bundle redaction smoke 결과를 포함해야 합니다. diff --git a/docs/operations/on-premises-package.md b/docs/operations/on-premises-package.md new file mode 100644 index 00000000..3837bfa9 --- /dev/null +++ b/docs/operations/on-premises-package.md @@ -0,0 +1,210 @@ +# On-Premises Package Checklist + +이 문서는 `pg-erd-cloud`를 고객 관리 환경에 설치하거나 평가 배포할 때 필요한 +상용 패키지 점검표입니다. SaaS 운영 문서와 달리, 네트워크가 제한된 고객 환경과 +운영자 인수인계를 기준으로 작성합니다. + +## Package Boundary + +패키지 기준 파일: + +- `compose.prod.yaml` +- `.env.example` +- `backend/Dockerfile` +- `frontend/Dockerfile.prod` +- `deploy/traefik/dynamic.yaml` +- `docs/legal/license-billing.md` +- `docs/operations/backup-restore.md` +- `docs/operations/billing-runtime.env.example` +- `docs/operations/restore-drills/restore-drill.example.json` +- `docs/operations/rollback-drills/rollback-drill.example.json` +- `docs/operations/migration-rollback.md` +- `docs/operations/incident-response.md` +- `docs/operations/alert-thresholds.md` + +`scripts/ci/validate_onprem_package.py`는 위 파일과 필수 문구를 정적으로 검사합니다. +`scripts/ci/validate_restore_drill_manifest.py`는 restore drill evidence manifest의 +필수 smoke 결과와 secret redaction 기준을 검사합니다. + +## Offline license + +- 운영자는 배포 환경 밖에서 Ed25519 keypair를 생성합니다. + - `cd backend && python -m app.license_tokens generate-keypair --format env` +- 배포 환경에는 `LICENSE_PUBLIC_KEY`만 설정합니다. +- 고객에게 전달하는 `X-LICENSE-KEY`는 signed offline token입니다. + - `cd backend && python -m app.license_tokens issue --private-key "$LICENSE_PRIVATE_KEY" --sub customer-acme --plan enterprise --jti license-2026-07 --exp 2027-07-02 --seats 25` +- `LICENSE_MODE=required`를 켠 판매 배포는 `LICENSE_KEY` 또는 + `LICENSE_PUBLIC_KEY` 중 하나가 없으면 시작하지 않아야 합니다. + +## Secret material + +- `APP_SECRET`은 앱 DB 백업과 분리 보관합니다. +- compose production path는 `APP_SECRET_FILE=/run/secrets/app_secret`을 사용하고, + secret file은 `./secrets/app_secret`에서 읽습니다. +- `APP_SECRET` 또는 secret file이 유실되면 저장된 DSN ciphertext를 복호화할 수 + 없으므로 restore 완료로 보지 않습니다. +- `POSTGRES_PASSWORD`, `BILLING_WEBHOOK_SECRET`, + `BILLING_WEBHOOK_SIGNATURE_SECRET`, provider secret, private license key는 저장소에 + 커밋하지 않습니다. production에서는 billing webhook shared secret은 24자 이상, + signature secret은 32자 이상이어야 합니다. + +## Revocation update + +- 토큰 단위 회수는 `LICENSE_REVOKED_TOKEN_IDS`로 배포합니다. +- 고객/계약 단위 회수는 `LICENSE_REVOKED_SUBJECTS`로 배포합니다. +- 계정 접근 중지는 `ACCOUNT_DEACTIVATED_SUBJECTS`로 배포하고, + `ACCOUNT_REACTIVATION_URL` 또는 `BILLING_SUPPORT_URL` 중 하나를 함께 제공합니다. +- 지원 운영자 read-only 진단 접근은 `SUPPORT_OPERATOR_SUBJECTS`로 제한합니다. + +## Air-gapped + +- 고객 환경에서 외부 registry 접근이 막혀 있으면 container image를 사전 반입하고, + `compose.prod.yaml`의 image digest 기준으로 무결성을 확인합니다. +- 짧은 수명 컨테이너로 내부 DB 또는 registry를 점검해야 하는 self-hosted runner에서는 + 고객 DNS 정책에 따라 `docker run --network host ...`가 필요할 수 있습니다. +- live LLM draft는 승인된 provider와 네트워크 정책이 없으면 비활성 상태를 유지합니다. + provider를 켜는 배포는 `LLM_DRAFT_QUOTA_ENABLED=true`와 환경별 + `LLM_DRAFT_QUOTA_REQUESTS`, `LLM_DRAFT_QUOTA_WINDOW_SECONDS` 값을 함께 설정합니다. + +## Restore drill + +- 운영 투입 전 `docs/operations/backup-restore.md`의 restore drill을 최소 1회 수행합니다. +- restore DB는 운영 DB가 아닌 격리 PostgreSQL입니다. +- restore 후 `/healthz`, `alembic current`, 프로젝트 조회, 공유 링크 조회/폐기, + SQL export smoke를 확인합니다. +- 결과는 릴리즈 승인 기록 또는 운영 이슈에 남기고, + `docs/operations/restore-drills/restore-drill.example.json` 형식의 manifest로 + 보존합니다. + +## Rollback drill + +- 운영 투입 전 `docs/operations/migration-rollback.md`의 dry-run 절차를 검토합니다. +- `alembic downgrade --sql -1`로 마지막 마이그레이션 rollback SQL을 + 확인합니다. +- 실제 downgrade는 staging 또는 restore drill DB에서 먼저 수행합니다. +- rollback 후에도 문제가 남으면 restore drill 절차로 전환합니다. +- 결과는 `docs/operations/rollback-drills/rollback-drill.example.json` 형식의 + manifest로 보존하고, 실제 고객/스테이징 증거는 + `python scripts/ci/validate_rollback_drill_manifest.py evidence/rollback-drill.customer.json` + 으로 직접 검증합니다. + +## Support bundle + +지원 요청을 받을 때 고객에게 요청할 최소 bundle: + +- 배포 commit SHA +- Billing provider catalog version +- `alembic current` 출력 +- `compose.prod.yaml` 변경 여부 +- `/healthz` 응답 +- 최근 backend error log +- `GET /api/billing/support/account?subject=` 결과에서 raw metadata와 + 공개 share URL/token을 제외한 summary +- 적용 중인 `LICENSE_MODE`, verifier 종류, revocation env 이름 목록 + +민감값(`APP_SECRET`, DB password, DSN, private key, billing secret, raw provider +metadata)은 support bundle에 포함하지 않습니다. + +운영자는 수집값을 직접 압축해 전달하기 전에 redaction-first 생성기를 실행합니다. +이 도구는 입력 파일에서 secret-like key, DSN credential, bearer token, raw provider +metadata, 공개 share URL/token을 `[redacted]`로 치환하고, env 파일은 안전한 +설정 존재 여부와 verifier 종류만 기록합니다. + +```bash +python scripts/operations/generate_support_bundle.py \ + --commit-sha "$(git rev-parse HEAD)" \ + --billing-provider-catalog-version "catalog-2026-07-02" \ + --env-file .env.production \ + --alembic-current-file evidence/alembic-current.txt \ + --healthz-file evidence/healthz.json \ + --support-account-file evidence/support-account.json \ + --backend-log-file evidence/backend-error.log \ + --output evidence/support-bundle.json +``` + +생성 후 `support-bundle.json`에서 raw `APP_SECRET`, DB password, DSN, private key, +billing secret, raw provider metadata, 공개 share URL/token이 남아 있으면 판매 +지원 증거로 사용하지 않습니다. +`docs/operations/support-bundles/support-bundle.example.json`은 지원 bundle의 승인 +가능한 구조를 보여주며, 다음 validator가 release gate에서 redaction과 필수 필드를 +검사합니다. + +```bash +python scripts/ci/validate_support_bundle.py evidence/support-bundle.json +``` + +판매 심사나 고객 인수인계에는 증거 파일 내용을 복사하지 않는 tamper-evident index를 +추가로 생성합니다. 이 index는 각 증거 파일의 path, size, SHA-256, gate 상태, +`sale_blockers`만 기록하므로 raw support bundle 내용이나 provider metadata를 다시 +배포하지 않고도 어떤 파일 세트로 승인했는지 재검증할 수 있습니다. + +```bash +python scripts/operations/build_commercial_evidence_index.py \ + --release-approval evidence/release-approval.customer.json \ + --restore-drill evidence/restore-drill.customer.json \ + --rollback-drill evidence/rollback-drill.customer.json \ + --support-bundle evidence/support-bundle.json \ + --billing-provider-catalog evidence/billing-provider-catalog.customer.json \ + --billing-runtime-evidence evidence/billing-runtime-evidence.json \ + --output evidence/commercial-evidence-index.json +``` + +## Billing provider catalog + +판매 후보는 결제/계약 provider의 실제 plan catalog와 event mapping을 +`docs/operations/billing-provider-catalog.example.json` 형식으로 기록해야 합니다. +이 manifest는 `BILLING_ALLOWED_PLANS`, checkout/portal/support URL, webhook secret +storage reference, entitlement event type, contract-state event type을 한 곳에서 +검증합니다. + +- `python scripts/ci/validate_billing_provider_catalog.py` 통과 +- 실제 provider 증거를 저장소에 커밋하지 않는 경우 + `python scripts/ci/validate_billing_provider_catalog.py evidence/billing-provider-catalog.customer.json` + 으로 외부 경로를 직접 검증 +- 실제 판매 증거 파일은 `*.example.json` 이름을 쓰지 않고, `example.com`, + fake commit SHA, 반복 SHA-256, `customer-acme` 같은 샘플 표식을 제거 +- raw webhook secret, signature secret, provider API key는 manifest에 넣지 않음 +- provider별 fulfillment SDK를 쓰는 배포라도 manifest의 plan/event 값과 배포 env가 + 일치해야 함 + +## Billing runtime evidence + +판매 또는 paid pilot 투입 전에는 승인된 billing provider catalog와 실제 배포 env가 +일치하는지 별도 증거로 남깁니다. 이 증거는 raw URL 값과 webhook secret 값을 +복사하지 않고 SHA-256 비교 결과, catalog/runtime 파일 digest, mismatch blocker만 +기록합니다. + +예시 env 구조는 `docs/operations/billing-runtime.env.example`에 있습니다. 실제 +판매 증거는 이 파일을 복사해 쓰는 것이 아니라, 고객/스테이징 배포의 승인된 +runtime env 파일과 실제 provider catalog로 생성해야 합니다. + +```bash +python scripts/operations/build_billing_runtime_evidence.py \ + --catalog evidence/billing-provider-catalog.customer.json \ + --env-file .env.production \ + --output evidence/billing-runtime-evidence.json +python scripts/ci/validate_billing_runtime_evidence.py evidence/billing-runtime-evidence.json +``` + +`BILLING_WEBHOOK_SECRET`와 `BILLING_WEBHOOK_SIGNATURE_SECRET`은 raw secret 값이 +아니라 `secret-manager:`, `vault:`, `aws-secretsmanager:`, +`gcp-secret-manager:`, `azure-key-vault:` 계열 secret storage reference로 +기록되어야 합니다. raw secret material이 env evidence 파일에 있으면 runtime +evidence는 sale-ready로 집계하지 않습니다. + +## Release Gate + +온프레미스 판매 후보는 다음을 모두 만족해야 합니다. + +- `python scripts/ci/validate_onprem_package.py` 통과 +- `python scripts/ci/validate_billing_provider_catalog.py` 통과 +- `python scripts/operations/build_billing_runtime_evidence.py --catalog evidence/billing-provider-catalog.customer.json --env-file .env.production --output evidence/billing-runtime-evidence.json` 통과 +- `python scripts/ci/validate_billing_runtime_evidence.py evidence/billing-runtime-evidence.json` 통과 +- `python scripts/ci/validate_restore_drill_manifest.py evidence/restore-drill.customer.json` 통과 +- `python scripts/ci/validate_rollback_drill_manifest.py evidence/rollback-drill.customer.json` 통과 +- `python scripts/ci/validate_commercial_release_approval.py evidence/release-approval.customer.json` 통과 +- `python scripts/ci/commercial_readiness_audit.py --strict --release-approval evidence/release-approval.customer.json --restore-drill evidence/restore-drill.customer.json --rollback-drill evidence/rollback-drill.customer.json --support-bundle evidence/support-bundle.json --billing-provider-catalog evidence/billing-provider-catalog.customer.json --billing-runtime-evidence evidence/billing-runtime-evidence.json` 통과 +- `python scripts/operations/build_commercial_evidence_index.py --release-approval evidence/release-approval.customer.json --restore-drill evidence/restore-drill.customer.json --rollback-drill evidence/rollback-drill.customer.json --support-bundle evidence/support-bundle.json --billing-provider-catalog evidence/billing-provider-catalog.customer.json --billing-runtime-evidence evidence/billing-runtime-evidence.json --output evidence/commercial-evidence-index.json` 통과 +- 이름만 바꾼 예시 manifest는 `commercial_readiness_audit.py --strict`에서 실패해야 함 +- backend tests, frontend unit/accessibility/E2E/visual/build 통과 +- 실제 판매 버전의 release approval manifest에 설치/지원 책임자와 승인일 기재 diff --git a/docs/operations/restore-drills/restore-drill.example.json b/docs/operations/restore-drills/restore-drill.example.json new file mode 100644 index 00000000..e1399a5f --- /dev/null +++ b/docs/operations/restore-drills/restore-drill.example.json @@ -0,0 +1,42 @@ +{ + "drill_id": "restore-drill-2026-07-02-staging", + "drill_date": "2026-07-02", + "environment": "staging-restore", + "operator": "SRE on-call", + "release_version": "v2026.07.02-commercial-candidate", + "commit_sha": "41f8b014f68813ba50fb9dca8dfdad280920fe2c", + "backup_artifact": { + "path": "backups/pg-erd-cloud-20260702T000000Z.dump", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": "2026-07-02T00:00:00Z" + }, + "restore_target": { + "postgres_image": "postgres:16-alpine@sha256:64b8bc2e6b2bdab4f0ce8019e9f8d71bc7f32b81a9a6ecb3f3d36cb4e5be1000", + "database": "erd_restore", + "isolated": true + }, + "app_secret_source": "vault://customer-acme/pg-erd-cloud/app-secret", + "alembic_current": "0004_billing_event", + "expected_alembic_revision": "0004_billing_event", + "healthz": { + "path": "/healthz", + "ok": true + }, + "smoke_tests": { + "authenticated_project_list": true, + "share_link_lookup": true, + "share_link_revoke_or_expiry": true, + "sql_export": true, + "support_bundle_redaction": true + }, + "timings_minutes": { + "backup": 7, + "restore": 12, + "application_smoke": 9 + }, + "evidence_links": [ + "https://github.com/ContextualWisdomLab/pg-erd-cloud/issues/415", + "docs/operations/backup-restore.md" + ], + "status": "passed" +} diff --git a/docs/operations/rollback-drills/rollback-drill.example.json b/docs/operations/rollback-drills/rollback-drill.example.json new file mode 100644 index 00000000..09bc98a7 --- /dev/null +++ b/docs/operations/rollback-drills/rollback-drill.example.json @@ -0,0 +1,49 @@ +{ + "drill_id": "rollback-drill-2026-07-02-staging", + "drill_date": "2026-07-02", + "environment": "customer-acme-staging", + "operator": "SRE on-call", + "release_version": "v2026.07.02-commercial-candidate", + "commit_sha": "41f8b014f68813ba50fb9dca8dfdad280920fe2c", + "migration_tool": "alembic", + "database": "erd_rollback", + "backup_artifact": { + "path": "backups/customer-acme/pg-erd-cloud-20260702T000000Z.dump", + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "created_at": "2026-07-02T00:00:00Z" + }, + "dry_run": { + "command": "alembic downgrade --sql 0005_llm_draft_usage_event 0004_billing_event", + "sql_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "reviewed": true + }, + "rollback": { + "command": "docker compose -f compose.prod.yaml run --rm backend alembic downgrade 0004_billing_event", + "pre_rollback_revision": "0005_llm_draft_usage_event", + "target_revision": "0004_billing_event", + "post_rollback_revision": "0004_billing_event", + "destructive_migration_reviewed": true, + "restore_fallback_available": true + }, + "healthz": { + "path": "/healthz", + "ok": true + }, + "smoke_tests": { + "authenticated_project_list": true, + "snapshot_lookup": true, + "share_link_lookup": true, + "sql_export": true, + "support_bundle_redaction": true + }, + "timings_minutes": { + "dry_run_review": 6, + "rollback": 4, + "application_smoke": 8 + }, + "evidence_links": [ + "https://github.com/ContextualWisdomLab/pg-erd-cloud/issues/415", + "docs/operations/migration-rollback.md" + ], + "status": "passed" +} diff --git a/docs/operations/support-bundles/support-bundle.example.json b/docs/operations/support-bundles/support-bundle.example.json new file mode 100644 index 00000000..2ceced48 --- /dev/null +++ b/docs/operations/support-bundles/support-bundle.example.json @@ -0,0 +1,120 @@ +{ + "backend_error_log_tail": [ + "database_url=postgresql://erd:[redacted]@postgres/erd", + "Authorization: [redacted]", + "provider_api_key=[redacted]" + ], + "billing": { + "environment_names": [ + { + "configured": true, + "name": "BILLING_ALLOWED_PLANS" + }, + { + "configured": true, + "name": "BILLING_CHECKOUT_URL" + }, + { + "configured": true, + "name": "BILLING_PORTAL_URL" + }, + { + "configured": true, + "name": "BILLING_SUPPORT_URL" + }, + { + "configured": true, + "name": "ACCOUNT_REACTIVATION_URL" + }, + { + "configured": true, + "name": "BILLING_ENTITLEMENT_EVENT_TYPES" + }, + { + "configured": true, + "name": "BILLING_CONTRACT_STATE_EVENTS_ENABLED" + } + ], + "provider_catalog_version": "catalog-2026-07-02", + "secret_environment_names": [ + { + "configured": true, + "name": "BILLING_WEBHOOK_SECRET" + }, + { + "configured": true, + "name": "BILLING_WEBHOOK_SIGNATURE_SECRET" + } + ] + }, + "bundle_id": "support-bundle-20260702T223000Z", + "bundle_schema_version": 1, + "database": { + "alembic_current": "0005_llm_draft_usage_event (head)" + }, + "deployment": { + "billing_provider_catalog_version": "catalog-2026-07-02", + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "compose_prod": { + "exists": true, + "path": "compose.prod.yaml", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + "generated_at": "2026-07-02T22:30:00Z", + "healthz": { + "ok": true + }, + "license": { + "mode": "required", + "revocation_environment_names": [ + { + "configured": true, + "name": "LICENSE_REVOKED_TOKEN_IDS" + }, + { + "configured": true, + "name": "LICENSE_REVOKED_SUBJECTS" + } + ], + "verifier": "signed_token" + }, + "redaction": { + "applied": true, + "rules": [ + "secret-like keys are replaced with [redacted]", + "raw provider metadata fields are replaced with [redacted]", + "public share URL/token fields are replaced with [redacted]", + "URL credentials and secret query parameters are redacted in text", + "bearer tokens and secret assignments are redacted in text" + ] + }, + "support_account_summary": { + "billing_support_url": "https://support.example.com/pg-erd-cloud", + "metadata": "[redacted]", + "recent_billing_events": [ + { + "metadata_summary": [ + { + "key": "seat_count", + "value": "25" + }, + { + "key": "api_key", + "value": "[redacted]" + } + ], + "provider": "contract-ledger", + "provider_event_id": "evt_20260702_support_001", + "target_plan": "enterprise" + } + ], + "recent_share_links": [ + { + "name": "Board review", + "share_url": "[redacted]" + } + ], + "subject": "customer-admin" + } +} diff --git a/docs/superpowers/plans/2026-07-02-krw-2b-commercialization.md b/docs/superpowers/plans/2026-07-02-krw-2b-commercialization.md new file mode 100644 index 00000000..c4ba0983 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-krw-2b-commercialization.md @@ -0,0 +1,533 @@ +# KRW 2B Commercialization Plan + +This plan is the concrete operating plan for making `pg-erd-cloud` credible as a +KRW 2B enterprise SaaS or on-premises product. KRW 2B is treated here as a buyer +confidence threshold for an enterprise-grade product package, not as a revenue +guarantee. + +The active GitHub PR is `#415` on `product/commercial-readiness`. Review-bot +latency and queued automation are not blockers. Only actual CI failures, +security defects, missing sellable features, and unverifiable deployment or +operating paths block release. + +FigJam operating model: +https://www.figma.com/board/XJXqiPUAYyrV85N5XzQpsB?utm_source=codex&utm_content=edit_in_figjam&oai_id=&request_id=abef7f56-0ca9-4a97-9173-0e6ecb254b71 + +Product Design support diagnostics audit: +https://www.figma.com/design/OTN0rBGtnVy0P7yq4Iv9Si?node-id=48-2 + +## Tool Roles + +### Superpowers + +- Maintain this execution plan as the source of truth for sequencing. +- Convert every gap into a testable task with a named file, acceptance criteria, + and verification command. +- Keep execution autonomous after the plan is written; do not wait for review + bot comments or approval-only delays. + +### Product Design + +- Audit the core product flows from the buyer's point of view: + authentication, first-run onboarding, project creation, connection setup, + snapshot creation, ERD editor use, share/export, billing handoff, and support. +- Classify UX findings as: + - `P0`: blocks trust, activation, security comprehension, or purchase. + - `P1`: reduces conversion, retention, or supportability. + - `P2`: polish or scale improvement. +- Keep every finding tied to a running-app screenshot, source component, Figma + node, and implementation task. + +### Figma + +- Do not use Figma Code Connect. +- Use Figma and FigJam for editable product screens, flow diagrams, QA boards, + buyer-trust walkthroughs, release-gate boards, and visual regression evidence. +- Extend the existing product design file and FigJam boards before creating + separate design artifacts. +- Use Figma screenshots only as evidence. The implementation source remains the + repository. + +### Data Analytics + +- Build the KRW 2B buyer-confidence model with transparent assumptions: + target customer profile, problem severity, price packaging, deployment model, + sales motion, support cost, activation funnel, retention risk, and expansion + path. +- Define the product KPIs and guardrails required before a KRW 2B valuation or + enterprise contract discussion: + activation rate, connection success rate, snapshot success rate, ERD render + latency, share/export success rate, license validation success, billing + reconciliation success, restore drill success, incident response times, and + support load. +- Mark missing market data explicitly instead of hiding it in estimates. + +### Ponytail + +- Keep the implementation boring and direct. +- Do not add a submodule now. A submodule would add operational friction without + a proven independent release boundary. +- Do not split a separate library now unless one of these triggers is met: + - A module is consumed by backend, frontend, CLI, and external customers. + - A customer needs independent versioning or vendoring. + - The module has a stable public API with tests and release notes. + - Keeping it inside the app makes security review or on-prem packaging harder. +- Current candidate for future extraction: + - `commercial-license-kit`: license token verification, revocation-list + parsing, offline issuance helpers, and contract fixtures. + - Keep it in-repo first under existing backend code until a second consumer + exists. If extraction becomes justified, prefer a normal package in a + monorepo workspace over a git submodule. + +## Commercial Release Gates + +### P0: No-Go Until Complete + +- Security: + production startup refuses weak secrets, missing OIDC issuer/audience, + unsafe CORS, missing target database allowlist, and unbounded public share + behavior. +- Authentication: + signed-in, signed-out, forbidden, and deactivated-account states are distinct + in API responses and UI copy. +- Authorization: + project ownership, membership, share-link permissions, and admin-only + operations are covered by tests. +- Data protection: + DSNs and tokens are redacted from logs, exports, errors, and telemetry. +- Billing and license: + signed license validation, revocation, usage limits, account deactivation, + reactivation link exposure, and plan-change handoff are implemented and + tested. +- Deployment: + Docker/prod compose path, environment documentation, database migration path, + backup/restore, and rollback runbook are reproducible. +- Quality: + backend tests/mypy, frontend typecheck/unit/build, E2E, accessibility, visual + regression, approval-manifest validation, and security scans have no real + failures on the current head. + +### P1: Paid Pilot Required + +- Billing provider reconciliation: + provider-neutral webhook/event ingestion records payment or contract events, + deduplicates events, maps them to account state, and exposes support evidence. +- Admin and support console: + authorized operators can inspect account state, license status, billing usage, + share links, and deactivation/reactivation context without direct database + access. +- Observability: + alerts have owner, severity, threshold, customer impact, and first response + guidance. Metrics cover auth failures, share failures, billing/license + failures, LLM draft failures, snapshot jobs, queue latency, and restore + drills. +- On-premises package: + offline license issuance, revocation list update, air-gapped deployment notes, + backup/restore drill, and upgrade rollback are documented and smoke-tested. +- UX buyer trust: + first-run, billing, share/export, errors, and empty states explain what + happened and what the user can do next. + +### P2: General Availability + +- Multi-browser visual baselines beyond Chromium for buyer-critical workflows. +- Customer-facing release notes and support playbooks for each commercial + release. +- Formal market sizing and pricing sensitivity evidence. +- Legal approval manifest filled with real approver/date/version records. +- Optional external package split if a second consumer proves the boundary. + +## Execution Work Packages + +### 1. Live Audit Refresh + +Files: + +- `docs/commercial-readiness.md` +- `docs/superpowers/plans/2026-07-02-krw-2b-commercialization.md` + +Tasks: + +- Recheck `main`, PR `#415`, unresolved review threads, required checks, and + branch mergeability. +- Treat queued review and bot-only lag as non-blocking. +- Record only real failed checks or unmerged high-risk gaps as blockers. + +Verification: + +```bash +gh pr view 415 --repo ContextualWisdomLab/pg-erd-cloud --json headRefOid,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup +gh pr checks 415 --repo ContextualWisdomLab/pg-erd-cloud --required +``` + +Acceptance: + +- Current head SHA is recorded in the final work summary. +- Any failed required check has a follow-up implementation task. + +### 2. Data Analytics Buyer Model + +Files: + +- `docs/business/krw-2b-market-kpi-model.md` + +Tasks: + +- Define ICP segments: + platform teams, data engineering teams, consulting/SI teams, regulated + database operations teams, and on-premises enterprise buyers. +- Create a transparent KRW 2B model: + enterprise license, on-prem support, annual recurring subscription, services, + and expansion scenarios. +- Define KPI thresholds: + activation, connection success, snapshot success, ERD render latency, + share/export success, license validation, billing reconciliation, restore + drill, incident response, and support effort. +- Instrument initial privacy-preserving lifecycle outcomes with + `product_events_total(area, action, outcome)` for project, connection, + snapshot, and share-link creation. +- Mark every assumption as `measured`, `estimated`, or `missing`. + +Verification: + +```bash +git diff --check docs/business/krw-2b-market-kpi-model.md +``` + +Acceptance: + +- No market claim is presented as proven without evidence. +- The model states what evidence must be collected before sales collateral can + make numeric claims. + +### 3. Product Design Audit Expansion + +Files: + +- `docs/ui-ux/product-design-audit.md` +- `docs/ui-ux/design-qa-checklist.md` +- `docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/README.md` + +Tasks: + +- Add buyer-trust checks for: + first-run onboarding, empty workspace, connection errors, billing limits, + plan-change handoff, account deactivation, share/export success, and support + escalation. +- Keep `/?demo-support=operator` available in `VITE_DEMO_MODE=true` as a + sales-engineering and Product Design evidence path for support diagnostics. +- Track support diagnostics UI follow-ups: + raw billing/support URLs are now named links with copy actions, and + narrow-width billing events preserve their labels in stacked rows. + Production-scale `stress-customer` fixtures now cover long provider events, + contract IDs, plan names, and timestamps with Playwright overflow checks. +- Convert every `P0` design issue into a frontend task with test coverage. + +Verification: + +```bash +git diff --check docs/ui-ux/product-design-audit.md docs/ui-ux/design-qa-checklist.md +``` + +Acceptance: + +- Every buyer-critical state has either existing visual evidence or a task to + add visual evidence. + +### 4. Figma And FigJam Commercial Board + +Files: + +- `docs/ui-ux/product-design-figma-execution-plan.md` +- Existing Figma design file +- Existing and new FigJam boards + +Tasks: + +- Link the KRW 2B commercial readiness FigJam board. +- Add a buyer walkthrough lane: + discovery, trial, security review, procurement, deployment, renewal. +- Add a release-gate lane: + P0 no-go, P1 paid pilot, P2 GA, evidence, owner, and verification command. +- Keep Code Connect unused. + +Verification: + +```bash +git diff --check docs/ui-ux/product-design-figma-execution-plan.md +``` + +Acceptance: + +- Figma/FigJam artifacts are linked from repository docs. +- The design artifacts explain release gates without becoming the source of + implementation truth. +- The FigJam board includes the billing reconciliation observability loop for + `billing_events_total` and the billing webhook alert. + +### 5. Billing Provider Reconciliation + +Files: + +- `backend/app/api/billing.py` +- `backend/app/schemas.py` +- `backend/tests/test_billing_usage.py` +- `docs/legal/license-billing.md` + +Status: + +- First provider-neutral event recording slice implemented in PR `#415`. +- Raw-body HMAC-SHA256 signature verification implemented in PR `#415` without + adding provider SDK dependencies. +- Billing webhook outcomes now emit `billing_events_total` labels for recorded, + duplicate, rejected authentication, rejected configuration, and rejected + catalog events. +- Configurable provider event alias normalization is implemented through + `BILLING_EVENT_TYPE_ALIASES`, including provider-scoped + `provider:event=normalized` entries. +- Normalized contract-state event application is implemented behind + `BILLING_CONTRACT_STATE_EVENTS_ENABLED`. +- Configurable plan catalog validation is implemented through + `BILLING_ALLOWED_PLANS` for plan-change requests and billing webhook + `target_plan` values. +- Billing provider catalog release gating is implemented through + `docs/operations/billing-provider-catalog.example.json` and + `scripts/ci/validate_billing_provider_catalog.py`, covering checkout, portal, + support URL, allowed plans, entitlement events, contract-state events, and + webhook secret storage references. +- Billing runtime evidence is implemented through + `scripts/operations/build_billing_runtime_evidence.py`, + `scripts/ci/validate_billing_runtime_evidence.py`, and + `docs/operations/billing-runtime.env.example`, proving catalog/env alignment + with SHA-256 comparisons while rejecting raw webhook secret material in + reviewed env evidence files. +- Provider-neutral checkout handoff is implemented through + `POST /api/billing/checkout` with `BILLING_CHECKOUT_URL` or support fallback. +- Support diagnostics derive provider-neutral entitlement evidence from the latest + `BILLING_ENTITLEMENT_EVENT_TYPES` event with `target_plan` and optional + `seat_count`/`seats` metadata. +- Project member invite now uses the same entitlement evidence to reject new + seats when the contracted seat count is already reached, while allowing role + updates for existing seated members. +- Support diagnostics now compare contracted seat count against active app seats + and return read-only deprovisioning candidates when the account is over limit. + This supports audited manual review without deleting customer access before a + customer-specific approval policy exists. +- Remaining gap: provider-specific fulfillment SDKs, customer portal deep + integration, real provider catalog approval, and customer-approved automatic + seat provisioning/deprovisioning. + +Tasks: + +- Add provider-neutral billing event schema: + provider, provider event id, event type, subject, target plan, occurred time, + status, and metadata redaction. +- Add idempotent event ingestion endpoint guarded by a shared secret or signed + provider token. +- Add optional raw-body HMAC-SHA256 signature verification for provider/gateway + webhooks. +- Add configurable provider event alias normalization before storing events. +- Record event outcomes for support diagnostics. +- Record reconciliation outcomes as low-cardinality Prometheus metrics and + alert on rejected auth/config/catalog events. +- Keep the existing portal/support handoff path. + +Verification: + +```bash +cd backend +PYTHONPATH=. uv run pytest -q tests/test_billing_usage.py +PYTHONPATH=. uv run mypy app +``` + +Acceptance: + +- Duplicate events do not double-apply state changes. +- Invalid signatures or missing secrets fail closed. +- No raw customer secrets appear in response bodies or logs. + +### 6. Admin Support Surface + +Files: + +- `backend/app/api/*` +- `backend/tests/*` +- `frontend/src/App.tsx` +- `frontend/src/api.ts` +- `frontend/src/styles.css` +- `frontend/src/App.accessibility.test.tsx` + +Status: + +- Backend read-only support diagnostics implemented in PR `#415` via + `SUPPORT_OPERATOR_SUBJECTS` and `GET /api/billing/support/account`. +- Operator frontend view implemented in PR `#415` via the `/api/me` + `support_operator` flag and a read-only `지원 진단` workspace screen. +- Recent share-link summaries are included without exposing public share URLs + or raw billing metadata. +- Remaining gap: audited destructive admin actions, if the sales/support motion + proves they are necessary. + +Tasks: + +- Expose read-only admin/support account diagnostics for account state, usage + limits, license verification mode, reactivation URL, support URL, and recent + share links and billing events. +- Add frontend support view only for authorized operators. +- Keep destructive actions out of the first implementation slice unless tests + and audit logs are in place. + +Verification: + +```bash +cd backend +PYTHONPATH=. uv run pytest -q +PYTHONPATH=. uv run mypy app +cd ../frontend +npm run test:a11y +npm run typecheck +npm run test -- --run +npm run build +``` + +Acceptance: + +- Non-admin users cannot access support diagnostics. +- Admin view is useful without requiring direct database access. + +### 7. On-Premises Packaging Drill + +Files: + +- `docs/operations/backup-restore.md` +- `docs/operations/migration-rollback.md` +- `docs/operations/on-premises-package.md` +- `docs/legal/license-billing.md` +- `deploy/*` +- `scripts/ci/validate_onprem_package.py` +- `scripts/operations/generate_support_bundle.py` +- `scripts/ci/validate_support_bundle.py` +- `scripts/ci/commercial_readiness_audit.py` +- `docs/operations/support-bundles/support-bundle.example.json` + +Status: + +- Static package smoke gate implemented in PR `#415`. +- Restore drill manifest gate implemented with + `scripts/ci/validate_restore_drill_manifest.py` and + `docs/operations/restore-drills/restore-drill.example.json`. +- Rollback drill manifest gate implemented with + `scripts/ci/validate_rollback_drill_manifest.py` and + `docs/operations/rollback-drills/rollback-drill.example.json`. +- Support bundle generator implemented with + `scripts/operations/generate_support_bundle.py`, redaction tests, and CI + execution. +- Support bundle evidence validator implemented with + `scripts/ci/validate_support_bundle.py` and + `docs/operations/support-bundles/support-bundle.example.json`. The validator + also accepts explicit generated bundle paths such as + `evidence/support-bundle.json`. +- Commercial readiness audit implemented with + `scripts/ci/commercial_readiness_audit.py` to separate schema-ready evidence + from actual non-example sale evidence. It now accepts uncommitted customer or + staging evidence paths through `--release-approval`, `--restore-drill`, + `--rollback-drill`, `--support-bundle`, `--billing-provider-catalog`, and + `--billing-runtime-evidence`; `*.example.json` names + and sample markers such as `example.com`, fake commit SHA, repeated SHA-256, + and `customer-acme` are still treated as example evidence, not real sale proof. +- Remaining gap: customer-environment restore/rollback evidence from a real + paid pilot or staging deployment. + +Tasks: + +- Add an on-prem deployment checklist covering offline license issue, + revocation-list update, secret rotation, backup, restore, upgrade, rollback, + and support bundle collection. +- Add a smoke command or script for validating the documented package path. + - Implemented: `python scripts/ci/validate_restore_drill_manifest.py`. + - Implemented: `python scripts/ci/validate_rollback_drill_manifest.py`. + - Implemented: `python scripts/operations/generate_support_bundle.py`. + - Implemented: `python scripts/ci/validate_support_bundle.py`. + - Implemented: `python scripts/ci/commercial_readiness_audit.py --strict`. + - Implemented: explicit external evidence path validation for release + approvals, restore drills, support bundles, billing catalogs, and the + aggregate commercial readiness audit. + - Implemented: explicit real-evidence hardening so renamed example manifests + with reserved sample domains, fake commit SHA, repeated SHA-256, or + `customer-acme` placeholders do not satisfy `--strict`. + - Implemented: repository-local non-example evidence validation, so committed + real evidence files must pass the same validator and sample-marker checks as + explicit external evidence paths. + - Implemented: top-level `sale_blockers` audit output, so a buyer or release + owner can see the exact schema validator failure or missing real-evidence + reason without reconstructing it from per-gate details. + - Implemented: `scripts/operations/build_commercial_evidence_index.py` to + generate a tamper-evident external evidence index with file paths, sizes, + SHA-256 digests, readiness state, and `sale_blockers` without copying raw + evidence contents. + - Implemented: `scripts/operations/build_billing_runtime_evidence.py` and + `scripts/ci/validate_billing_runtime_evidence.py` to prove a deployment env + matches the approved billing provider catalog without copying raw runtime + values or webhook secrets. + +Verification: + +```bash +git diff --check docs/operations/backup-restore.md docs/operations/migration-rollback.md docs/legal/license-billing.md +python -m pytest scripts/operations/test_generate_support_bundle.py scripts/ci/test_validate_support_bundle.py scripts/ci/test_commercial_readiness_audit.py -q +python scripts/ci/commercial_readiness_audit.py --output /tmp/commercial-readiness-audit.json +python scripts/ci/validate_support_bundle.py +python scripts/ci/validate_support_bundle.py evidence/support-bundle.json +python scripts/ci/validate_restore_drill_manifest.py +python scripts/ci/validate_rollback_drill_manifest.py +python scripts/operations/build_billing_runtime_evidence.py --catalog docs/operations/billing-provider-catalog.example.json --env-file docs/operations/billing-runtime.env.example --output /tmp/billing-runtime-evidence.json +python scripts/ci/validate_billing_runtime_evidence.py /tmp/billing-runtime-evidence.json +python scripts/ci/commercial_readiness_audit.py --strict --release-approval evidence/release-approval.customer.json --restore-drill evidence/restore-drill.customer.json --rollback-drill evidence/rollback-drill.customer.json --support-bundle evidence/support-bundle.json --billing-provider-catalog evidence/billing-provider-catalog.customer.json --billing-runtime-evidence evidence/billing-runtime-evidence.json +python scripts/operations/build_commercial_evidence_index.py --release-approval evidence/release-approval.customer.json --restore-drill evidence/restore-drill.customer.json --rollback-drill evidence/rollback-drill.customer.json --support-bundle evidence/support-bundle.json --billing-provider-catalog evidence/billing-provider-catalog.customer.json --billing-runtime-evidence evidence/billing-runtime-evidence.json --output evidence/commercial-evidence-index.json +``` + +Acceptance: + +- A buyer can evaluate whether the product can be installed and supported + without SaaS connectivity. + +### 8. CI And PR Loop + +Files: + +- PR `#415` +- `.github/workflows/*` +- `scripts/ci/*` + +Tasks: + +- Monitor required checks on the current head. +- Ignore queue latency. +- Fix real failures immediately in the same branch when scoped to the current + commercialization work. +- Avoid widening workflow permissions unless the failure proves a real + permission gap. + +Verification: + +```bash +gh pr checks 415 --repo ContextualWisdomLab/pg-erd-cloud --required +``` + +Acceptance: + +- Required checks on the latest head are successful or only queued/pending. +- Any failed check has a commit that directly addresses the failing log. + +## First Autonomous Slice + +Execute these next, in order: + +1. Commit this KRW 2B plan and link it from commercial readiness docs. +2. Add `docs/business/krw-2b-market-kpi-model.md` with the initial Data + Analytics KPI and assumption model. +3. Update the Figma/Product Design execution doc with the new FigJam board and + no-Code-Connect constraint. +4. Re-run lightweight doc validation. +5. Push to PR `#415`. +6. Recheck required PR checks and only intervene on actual failures. diff --git a/docs/ui-ux/README.md b/docs/ui-ux/README.md index 5a5d50c0..c04a0fac 100644 --- a/docs/ui-ux/README.md +++ b/docs/ui-ux/README.md @@ -8,6 +8,35 @@ These reference screens define the intended product direction for Cloud ERD UI w - Modals should be centered, restrained, and form-first. - The ERD editor should prioritize canvas space, toolbars, side properties, sharing, and export workflows. +Execution planning: + +- [Product Design and Figma execution plan](product-design-figma-execution-plan.md) +- [Product Design UX audit](product-design-audit.md) +- [Design QA checklist](design-qa-checklist.md) +- [Visual regression baseline policy](visual-regression-baseline.md) +- [Core user flow Mermaid source](core-user-flow.mmd) +- [Product Design Kit Figma file](https://www.figma.com/design/OTN0rBGtnVy0P7yq4Iv9Si) + with the nine screenshots placed on `01 Current References`, local variables + on `02 Foundations`, component sets on `03 Components`, and a desktop core + flow prototype on `04 Core Flow Prototype` +- [Support diagnostics audit evidence](qa/2026-07-02-support-diagnostics-audit/README.md) + with current-run local browser screenshots and Figma section + +- [PR #415 commercial readiness evidence board](qa/2026-07-02-commercial-readiness-evidence-board.png) + with Figma section + +- [Core user flow FigJam board](https://www.figma.com/board/kHs1cKzwGzkNIBNaMt0xVq?utm_source=codex&utm_content=edit_in_figjam&oai_id=&request_id=b079e329-893d-4418-8909-b22a816fa588) + +Current Figma implementation anchors: + +- [Foundations](https://www.figma.com/design/OTN0rBGtnVy0P7yq4Iv9Si?node-id=17-2) +- [ERD table node variants](https://www.figma.com/design/OTN0rBGtnVy0P7yq4Iv9Si?node-id=25-78) +- [Toolbar button variants](https://www.figma.com/design/OTN0rBGtnVy0P7yq4Iv9Si?node-id=28-33) +- [Share/export modal states](https://www.figma.com/design/OTN0rBGtnVy0P7yq4Iv9Si?node-id=29-143) +- [Core flow prototype](https://www.figma.com/design/OTN0rBGtnVy0P7yq4Iv9Si?node-id=32-2) +- [Support diagnostics Product Design audit](https://www.figma.com/design/OTN0rBGtnVy0P7yq4Iv9Si?node-id=48-2) +- [PR #415 commercial readiness evidence](https://www.figma.com/design/OTN0rBGtnVy0P7yq4Iv9Si?node-id=63-2) + Screens: 1. `01-login-screen.png` diff --git a/docs/ui-ux/design-qa-checklist.md b/docs/ui-ux/design-qa-checklist.md new file mode 100644 index 00000000..3d1818e4 --- /dev/null +++ b/docs/ui-ux/design-qa-checklist.md @@ -0,0 +1,168 @@ +# Design QA Checklist + +Use this checklist before implementing or merging UI changes derived from the +Product Design and Figma work. It is not a final QA report by itself; a passing +QA decision requires side-by-side evidence from both a source visual target and a +rendered implementation. + +## Required Evidence + +- Source visual target: + - Figma design node, FigJam flow board, screenshot, mockup, or `docs/ui-ux` + reference PNG. + - Record the URL or file path. +- Rendered implementation: + - Local or deployed app screenshot of the same state. + - Record the URL, viewport, browser, and screenshot file path. +- Same-state match: + - Same route or workspace view. + - Same selected project/diagram state. + - Same modal open/closed state. + - Same data density where possible. + - Same viewport class: desktop editor, narrow review layout, or modal-focused + capture. + +## Current Figma Baseline + +Use these source nodes for the first implementation comparison pass: + +- Component foundations: + +- ERD table node density variants: + +- Editor toolbar button variants: + +- Share/export modal states: + +- Core flow prototype dashboard: + + +The Figma component and prototype screenshots were checked for clipped text and +overlap during the design pass. A code implementation still requires a separate +browser screenshot comparison before merge. + +## Required Screens + +Run the QA pass on these screens before broad UI implementation: + +1. Auth gate or signed-in entry. +2. Dashboard with recent projects and diagrams. +3. Project list with empty and populated states. +4. Diagram list with empty, running, succeeded, and failed statuses when + available. +5. First-run setup path for project, connection, DSN, schema filter, and + snapshot creation. +6. ERD editor with at least four tables, relationships, minimap, toolbar, and + search. +7. ERD editor empty state and snapshot-running state. +8. Table node stress state with long names, comments, examples, PK/FK badges, + NOT NULL badges, indexes, and business group badge. +9. Add/edit table modal. +10. Relationship settings modal. +11. Business group modal. +12. Cardinality recommendation modal. +13. Share/export modal with no project, loading, success, copy-feedback, error, + and no-DDL states. +14. Narrow viewport review/share state. +15. Support diagnostics with an authorized operator, populated account summary, + billing/reactivation URLs, recent share links, recent billing events, and + narrow viewport fallback. Include long provider event, contract ID, plan + name, and timestamp fixtures. + +## Fidelity Surfaces + +### Fonts and Typography + +- Product font family follows the source target and implementation CSS. +- Heading, body, table, caption, badge, and monospace export text sizes are + visually consistent. +- Line heights do not crop Korean or English text. +- Long schema, table, column, index, and URL values wrap or truncate + intentionally. +- Button and toolbar labels fit their controls at all required viewport widths. + +### Spacing and Layout Rhythm + +- Sidebar width, padding, and section gaps match the design target. +- Dashboard cards and tables align to the same grid rhythm. +- Toolbar groups do not overlap the canvas, minimap, empty state, or React Flow + controls. +- Modals preserve header, section, form, and action spacing at desktop and narrow + widths. +- Table node density remains scannable with high metadata volume. + +### Colors and Visual Tokens + +- Primary blue, active states, focus ring, borders, neutral text, status colors, + and modal shadows map to named Figma variables or CSS custom properties. +- Disabled states remain legible and distinguishable from active controls. +- Status pills communicate succeeded, failed, running, and not-found states + without relying on color alone. +- Business group colors remain distinguishable against table-node backgrounds. + +### Image and Asset Fidelity + +- Use real source screenshots, Figma captures, product icons, or an approved icon + library. +- Do not replace product-relevant icons or screenshots with placeholder boxes, + CSS drawings, text glyphs, or handcrafted approximations. +- ERD canvas captures must show real table-node content, relationship lines, and + controls rather than blank canvas placeholders. + +### Copy and Content + +- Korean and English labels use one consistent product voice per surface. +- Toolbar, modal, and disabled-state copy explains the next action. +- Share/export copy distinguishes project share links from DDL text and diagram + file exports. +- Support diagnostics copy distinguishes customer account status, license + verifier, billing portal, reactivation path, and support handoff without + exposing raw billing metadata. +- DSN and schema-filter hints are explicit about PostgreSQL and Snowflake. +- Error states say what failed and what the user can try next. + +## Accessibility Checks + +- Keyboard can reach every visible control in a logical order. +- Skip link lands on the main workspace. +- Focus ring is visible against all backgrounds. +- Dialogs trap focus, close on Escape, and return focus to the opener. +- Destructive actions are not adjacent to primary actions without enough visual + separation. +- `aria-live` regions announce snapshot, copy, loading, and search-result + changes without excessive noise. +- Icon-only toolbar actions have accessible names and visible tooltips. +- Tables and table-like rows expose useful structure to assistive tech. + +## QA Report Format + +Save each implementation QA pass as `docs/ui-ux/design-qa-report.md` or as a +dated file under `docs/ui-ux/qa/`. + +Required fields: + +- Source visual truth path or Figma node URL. +- Implementation URL and screenshot path. +- Viewport and state. +- Full-view comparison evidence. +- Focused-region comparison evidence for toolbar, table node, and modal details, + or a reason focused regions were not needed. +- Visual baseline update evidence when screenshots under + `frontend/e2e/__screenshots__` change. Follow + `visual-regression-baseline.md` for approval requirements. +- Findings ordered by severity. +- Fix checklist. +- Final result: `passed` or `blocked`. + +Use `passed` only when no actionable P0, P1, or P2 findings remain. P3 polish +can remain as follow-up. Use `blocked` when any actionable P0, P1, or P2 finding +remains. + +## First QA Target + +Start with the share/export modal because it is the recommended first +implementation surface from `product-design-audit.md` and has a clear existing +reference in `09-share-export-modal.png`. + +First source target: + diff --git a/docs/ui-ux/product-design-audit.md b/docs/ui-ux/product-design-audit.md new file mode 100644 index 00000000..82854540 --- /dev/null +++ b/docs/ui-ux/product-design-audit.md @@ -0,0 +1,362 @@ +# Product Design UX Audit + +Date: 2026-07-02 + +Audit scope: `pg-erd-cloud` workspace from project entry through ERD inspection, +editing, share, and export. + +Evidence used in this pass: + +- Repository screenshots in `docs/ui-ux/01-login-screen.png` through + `docs/ui-ux/09-share-export-modal.png`. +- Current-run support diagnostics audit screenshots in + `docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/`, covering desktop + support diagnostics, narrow support diagnostics, and commercial demo + dashboard. +- Figma Product Design Kit + , where the same nine + screenshots are placed on `01 Current References` and the support diagnostics + audit is placed in section `48:2`. +- Current frontend source in `frontend/src/App.tsx`, + `frontend/src/styles.css`, `frontend/src/erd/TableNode.tsx`, and + `frontend/src/components/modals/*`. + +Evidence limits: + +- This is a repository-plus-reference audit, not a completed browser-observed + usability study. The support diagnostics slice now has current-run browser + capture evidence, but the older nine-screen core workspace reference still + needs a full live-flow browser audit. +- Screenshots show the intended product direction, while source review shows the + current implementation. Live keyboard traversal, screen-reader output, backend + latency, and real database-scale behavior still need browser verification. + +## Figma Follow-Through Status + +The first Figma pass now covers the highest-priority audit findings: + +- Setup workflow: `04 Core Flow Prototype` includes a guided connection setup + frame between dashboard and ERD editor. +- Toolbar density: `PG ERD Toolbar Button` defines fixed-size icon and format + button variants for the editor toolbar. +- Share/export overload: `PG ERD Share Export Modal` separates share links, + SQL DDL, diagram image, and Mermaid export rows, with ready, copied, and error + states. +- Modal consistency: the share/export modal component provides the first modal + shell candidate for implementation alignment. +- Table node density: `PG ERD Table Node` defines compact, standard, and detail + variants. +- Token drift: `02 Foundations` defines local Figma variables and text/effect + styles that can be mirrored into frontend CSS custom properties. + +Implementation is still pending. Treat the Figma work as the visual source for +the next focused code PR, not as proof that the production UI already changed. + +## Flow Steps + +1. Login/auth gate + - Health: usable direction, needs live validation. + - Evidence: `01-login-screen.png`, auth-gate code in `frontend/src/App.tsx`. + +2. Dashboard overview + - Health: strong information architecture direction. + - Evidence: `02-dashboard-screen.png`; current dashboard, metrics, project + cards, and recent diagram table in `frontend/src/App.tsx`. + +3. Project list + - Health: clear enough for paid-pilot review, needs density and empty-state polish. + - Evidence: `03-project-list-screen.png`; project list rows and inline create + path in `frontend/src/App.tsx`. + +4. Diagram list + - Health: clear enough for snapshot browsing, needs state clarity. + - Evidence: `04-diagram-list-screen.png`; `DiagramTable` and status pills in + `frontend/src/App.tsx` and `frontend/src/styles.css`. + +5. New diagram / connection setup + - Health: functionally present, but the workflow is split between reference + modal direction and current editor-sidebar implementation. + - Evidence: `05-new-diagram-modal.png`; project, connection, DSN, schema + filter, and snapshot controls in `frontend/src/App.tsx`. + +6. ERD editor + - Health: strongest surface in the product, with dense controls and useful + accessibility hooks; needs clearer visual grouping. + - Evidence: `06-erd-editor-main.png`; toolbar, React Flow canvas, empty/busy + state, minimap, controls, and table nodes in `frontend/src/App.tsx`, + `frontend/src/styles.css`, and `frontend/src/erd/TableNode.tsx`. + +7. Add/edit entity + - Health: covers core editing, but modal styling is less consistent than the + newer modal shell. + - Evidence: `07-add-entity-modal.png`; `EditTableModal.tsx`, + `AddTableModal.tsx`, and modal CSS. + +8. Relationship settings + - Health: usable for relationship labeling, needs clearer destructive and + save/cancel hierarchy. + - Evidence: `08-relationship-settings-modal.png`; `EditEdgeModal.tsx`. + +9. Share/export + - Health: strategically important and already implemented as a combined + modal; needs clearer separation of sharing, DDL, and image/diagram exports. + - Evidence: `09-share-export-modal.png`; `ExportModal.tsx` and export toolbar + entry points in `frontend/src/App.tsx`. + +## Findings + +### P1. Support diagnostics is now demoable, with narrow-width evidence improved + +Evidence: + +- `docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/01-support-diagnostics-desktop.png` + shows the read-only support operator view after looking up `customer-owner`. +- `docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/02-support-diagnostics-mobile.png` + shows the same flow at 390px width. +- Figma section: + +- The capture used `VITE_DEMO_MODE=true` and `/?demo-support=operator`; Figma + Code Connect was not used. + +Impact: + +- Desktop support diagnostics are credible for paid-pilot support demos: counts, + account state, license verifier, named support/billing/reactivation links, + URL copy actions, recent share-link summaries, and recent billing events are + all visible in one operator flow. +- Narrow support diagnostics now reflows the summary cards, account details, + URL actions, share-link summaries, and billing events without hiding key row + context. +- Production-like stress fixture evidence now covers long provider names, event + names, contract IDs, plan names, and timestamps through the `stress-customer` + demo subject and Playwright overflow checks. +- The remaining risk is real customer support data: representative production + payloads still need a browser-observed review before treating mobile support + diagnostics as the primary support workflow. + +Recommendation: + +- Keep the support diagnostics implementation as an operator-only read path. +- Keep billing/support URLs as named links with copy actions rather than raw + full URLs in the account detail cards. +- Preserve the stacked narrow-width support table labels and keep the + `stress-customer` fixture in E2E before claiming mobile support workflow + completeness. +- Preserve the demo-only `?demo-support=operator` path as Product Design and + sales-engineering evidence, not as a production authorization shortcut. + +### P1. Setup workflow is split between the product concept and current editor sidebar + +Evidence: + +- The reference set includes a new-diagram modal, while the current implementation + exposes project selection, project creation, connection creation, DSN entry, + schema filtering, and snapshot creation inside the editor sidebar. +- In `frontend/src/App.tsx`, the editor-only sidebar controls run from project + selection through `Reverse engineer -> snapshot`. + +Impact: + +- First-time users can complete the task, but the path is operationally dense + before they understand what the ERD editor is for. +- The dashboard and list screens sell a workspace model, while the editor sidebar + carries setup, configuration, status, and navigation at once. + +Recommendation: + +- Keep the sidebar for selected-project context and advanced controls. +- Move first-run project/connection/snapshot creation into a guided "New + diagram" or "Create snapshot" modal/drawer that matches `05-new-diagram-modal`. +- In Figma, prototype both states: first-run setup and returning-user editor. + +### P1. ERD toolbar has many compact controls with mixed symbolic labels + +Evidence: + +- Toolbar labels include `↔`, `↶`, `+`, `◇`, `#`, `SQL`, `↗`, `IMG`, `UML`, and + `{}` in `frontend/src/App.tsx`. +- CSS gives each toolbar button a compact 44 x 40 minimum target. + +Impact: + +- The toolbar is dense, which is appropriate for an editor, but the visual + language mixes symbols, abbreviations, and text labels. +- Screen-reader labels are present, but sighted users must rely on hover titles + for several actions. + +Recommendation: + +- Define a Figma toolbar component with grouped regions: layout, create/edit, + analysis, share/export. +- Replace symbolic labels with consistent icon buttons plus tooltips, keeping + visible text only where the term itself is the format (`SQL`, `UML`). +- Add the grouped toolbar to the first implementation PR only after the Figma + direction is approved, because tests currently assert several visible labels. + +### P1. Share/export is strategically central but visually overloaded + +Evidence: + +- The reference screen shows share and export as two side-by-side concepts. +- `ExportModal.tsx` combines project share-link generation and DDL copy in a + single modal. Separate toolbar actions also trigger SVG, PlantUML, and Mermaid + exports directly from the toolbar. + +Impact: + +- Users may not understand which exports are project-level share links, which + are current-canvas text exports, and which are immediate file downloads. +- This matters because share links expose API-backed project resources while DDL, + SVG, PlantUML, and Mermaid are different artifact types. + +Recommendation: + +- In Figma, redesign share/export as a two-column or tabbed modal with clear + sections: Share link, SQL DDL, Diagram image, Diagram text formats. +- Add explicit disabled reasons and copy feedback to every export type. +- In code, route all export-related actions through one modal once the visual + model is approved. + +### P2. Modal system is partly consolidated and partly bespoke + +Evidence: + +- `ExportModal.tsx` uses `modalContent`, `modalHeader`, and structured sections. +- `EditTableModal.tsx` still uses a `modal` class with inline width, max-height, + overflow, row, spacing, and button styles. +- `useDialogAccessibility.ts` provides useful focus trapping, Escape handling, + and focus return behavior. + +Impact: + +- Accessibility behavior is on the right track, but visual consistency and + maintainability are uneven across modals. +- Inline styles make it harder to map modals cleanly into Figma components and + design tokens. + +Recommendation: + +- Create a Figma `Modal/Shell` component and variant rules for default, + destructive, form-heavy, and split-section modals. +- Refactor modal CSS toward shared classes before broad visual polish. +- Keep `useDialogAccessibility` as a strength and verify it with keyboard tests. + +### P2. ERD table nodes optimize density, but large schemas need progressive disclosure + +Evidence: + +- `TableNode.tsx` renders title, comment, group badge, PK/FK badges, column + comments, example values, data types, NOT NULL badges, and indexes. +- The component renders up to 25 columns and four indexes before showing a + "more" indicator. + +Impact: + +- Dense schema context is valuable, but nodes can become tall and visually noisy + as comments, examples, and indexes accumulate. +- Users auditing relationships may need a quick "keys only" or "compact" mode + before reading all metadata. + +Recommendation: + +- In Figma, define three table-node density variants: compact, standard, and + detail. +- Keep comments/examples/indexes available, but make detail expansion explicit. +- Validate with a stress case containing long schema/table names, 25+ columns, + multiple indexes, and business group badges. + +### P2. Responsive behavior exists, but mobile/narrow-width product intent is unclear + +Evidence: + +- CSS includes breakpoints at `1180px` and `767px`. +- At small widths, the sidebar becomes a top region, nav becomes four columns, + data tables scroll horizontally, and the canvas toolbar stretches across the + top of the canvas. + +Impact: + +- The product is primarily a desktop ERD editor. Narrow layouts are present, but + the expected mobile task is not defined. +- Without a target task, mobile work may become a compromised version of a + desktop editor rather than a useful review/share experience. + +Recommendation: + +- Define narrow-width scope as "review and share" unless full mobile editing is + explicitly required. +- In Figma, create a narrow review-mode screen that prioritizes project summary, + diagram preview, search, and share/export over full canvas editing. + +### P3. Design tokens are implicit in CSS rather than explicit in a system + +Evidence: + +- CSS repeats key colors such as `#034ea2`, `#e5e7eb`, `#64748b`, `#0f172a`, + `#f8fafc`, and `#fff`. +- Radius, spacing, focus ring, table density, and modal shadow values are defined + directly in component classes. + +Impact: + +- The visual direction is coherent, but Figma cannot stay linked to code without + named tokens. +- Future PRs may drift if each screen hardcodes spacing and colors independently. + +Recommendation: + +- Define Figma variables for color, spacing, radius, shadow, typography, focus, + and status. +- Mirror the naming in CSS custom properties before the first broad visual PR. + +### P3. Current screenshot references are useful but not yet an editable design system + +Evidence: + +- `docs/ui-ux` contains nine raster reference screens and a concise direction + note. +- The Figma file now contains editable pages, component sets, variables, and a + prototype, but the repository still needs to keep this status synchronized as + the design evolves. + +Impact: + +- The team has product direction and an editable Figma source, but future PRs + can still drift if design status, token names, and implementation handoff + notes are not maintained together. + +Recommendation: + +- Keep `docs/ui-ux/product-design-figma-execution-plan.md` and + `docs/ui-ux/README.md` synchronized with important Figma node links. +- Add a design QA report after the first implementation PR compares live app + screenshots against the Figma source. + +## Strengths To Preserve + +- The existing direction is appropriately work-focused: no marketing hero, + restrained color, compact tables, and a persistent workspace frame. +- Accessibility basics are already considered: skip link, focus-visible styles, + dialog focus trap, Escape close behavior, focus return, `aria-live`, and + `role="alert"` appear in the implementation. +- ERD-specific capabilities are real product differentiators: React Flow canvas, + table search/highlight, auto-layout, business grouping, cardinality + recommendations, generated examples, and multiple export formats. +- The reference screenshots already cover the major workspace states needed for + a first Figma design system pass. + +## Recommended First PR After Figma Review + +Start with the share/export surface because it is central to collaboration, +already has a focused component (`ExportModal.tsx`), and has clear reference +material in `09-share-export-modal.png` plus a dedicated Figma component set: +. + +Suggested PR scope: + +- Redesign the modal into clearer sections for share link, DDL, and diagram + exports. +- Keep existing API behavior unchanged. +- Add or update tests for disabled states, copy feedback, and error rendering. +- Verify with `cd frontend && npm run typecheck`, `cd frontend && npm test`, and + `cd frontend && npm run build`. diff --git a/docs/ui-ux/product-design-figma-execution-plan.md b/docs/ui-ux/product-design-figma-execution-plan.md new file mode 100644 index 00000000..43f95002 --- /dev/null +++ b/docs/ui-ux/product-design-figma-execution-plan.md @@ -0,0 +1,261 @@ +# Product Design and Figma Execution Plan + +This plan turns the current `pg-erd-cloud` product surface into a repeatable Product Design +and Figma workflow. It is grounded in the current product surface, not a generic +design exercise. + +## Current Product Evidence + +- Product: PostgreSQL and Snowflake schema reverse engineering, ERD rendering, + snapshot export, reversing-spec generation, and share links. +- Frontend: React 19, Vite, and React Flow under `frontend/src`. +- Existing UI reference source: `docs/ui-ux/README.md` and the nine PNG screens + in `docs/ui-ux`, now placed on the Figma `01 Current References` page. +- Core flow source: `docs/ui-ux/core-user-flow.mmd`. +- Product Design Kit Figma file: + . +- PR #415 commercial readiness evidence section: + . +- Generated FigJam flow board: + . +- Generated KRW 2B commercial readiness FigJam board: + . +- Current visual direction: quiet work-focused layout, persistent left sidebar, + light palette, blue primary actions, thin borders, subtle shadows, compact + tables, and dense ERD editor controls. +- Product font basis in code: `system-ui, -apple-system, Segoe UI, Roboto, + sans-serif`. + +## Design Brief + +Design and validate a practical Cloud ERD workspace for database engineers and +technical teammates who need to connect a database, create a schema snapshot, +inspect the ERD, adjust metadata, export DDL or diagrams, and share the result. +The interface should feel like an operational tool: dense, calm, keyboard and +screen-reader aware, and optimized for repeated use rather than marketing. + +## Execution Status + +Status as of 2026-07-02: + +- Figma file structure is in place with pages for intake, current references, + foundations, components, core flow prototype, and QA/handoff. +- `01 Current References` contains all nine repository screenshots as image-fill + cards using `FIT` scale mode. +- `02 Foundations` contains local Figma variables for primitives, semantic + colors, spacing, radius, text styles, modal shadow, and focus ring. The + variable WEB code syntax uses proposed CSS custom property names because the + current frontend still hardcodes the corresponding values. +- `03 Components` contains editable component sets for button, input field, + ERD table node, status pill, toolbar button, and share/export modal. +- `04 Core Flow Prototype` contains four connected desktop frames: + dashboard, connection setup, ERD editor, and share/export. The main CTAs use + Figma `ON_CLICK -> NAVIGATE` prototype reactions. + +Key Figma nodes: + +- Foundations summary: + +- Button component set: + +- Input field component set: + +- ERD table node component set: + +- Status pill component set: + +- Toolbar button component set: + +- Share/export modal component set: + +- Core flow prototype dashboard: + +- PR #415 commercial readiness evidence: + + +Visual checks completed: + +- Foundation summary rendered without clipped text. +- Button, input, table node, status pill, toolbar button, and share/export modal + component sets were screenshot-checked for overlap and text clipping. +- The ERD editor and share/export prototype frames were screenshot-checked after + composition. +- The PR #415 commercial readiness evidence board was screenshot-checked and + stored as `docs/ui-ux/qa/2026-07-02-commercial-readiness-evidence-board.png`. + +## Work Packages + +### 1. Product Design UX Audit + +Scope: + +- Login/auth gate. +- Dashboard and recent-project/recent-diagram overview. +- Project list and diagram list. +- ERD editor canvas, toolbar, minimap, table nodes, and empty/busy states. +- Add/edit table, relationship, business group, cardinality, share/export + modals. + +Audit checks: + +- Whether the primary workflow is obvious: create project, add connection, + create snapshot, open ERD, share or export. +- Whether disabled states explain what is required next. +- Whether the editor uses canvas space efficiently on desktop and narrower + screens. +- Whether toolbar labels, icon-only controls, focus states, live regions, and + dialog focus traps are sufficient. +- Whether table nodes remain legible with many columns, comments, examples, + indexes, PK/FK badges, and business groups. +- Whether share/export choices map cleanly to current API capabilities. + +Deliverable: + +- `docs/ui-ux/product-design-audit.md` with findings ordered by severity, each + tied to the relevant screen, source component, and proposed fix. + +### 2. Figma File and Design System Setup + +Required input: + +- Existing Figma design file URL or file key, or a Figma plan key so a new file + can be created. + +Proposed Figma pages: + +- `00 Intake`: product brief, source links, open decisions. +- `01 Current References`: the nine existing `docs/ui-ux` screenshots and any + captured running-app screens. +- `02 Foundations`: color, typography, radius, spacing, shadow, focus, and + status tokens. +- `03 Components`: sidebar, nav item, button, input, select, metric card, data + table, status pill, ERD table node, toolbar button, modal shell, share/export + controls. +- `04 Core Flow Prototype`: connected screens for connection-to-share workflow. +- `05 QA and Handoff`: visual diffs, acceptance checklist, implementation notes. + +Figma tool use: + +- Do not use Figma Code Connect for this project track. +- Use `get_libraries` and `search_design_system` before recreating components, + variables, or styles. +- Use `generate_figma_design` to capture the running web app into the target + Figma file when a local or deployed URL is available. +- Use `use_figma` to build editable screens from components, tokens, and + auto-layout frames, using the app font family confirmed from CSS. +- Validate with Figma screenshots after each major section and verify no text is + clipped or overlapping. + +Deliverable: + +- Editable Figma file containing the source references, componentized screens, + and a clickable prototype. +- Initial Figma file created with pages for intake, current references, + foundations, components, core prototype flow, and QA/handoff. +- `01 Current References` populated with all nine repository screenshots as + editable image-fill cards using `FIT` scale mode. +- Local variables and component sets now exist in the Figma file. The next code + step is to mirror the token names into CSS custom properties before broader + visual implementation. + +### 3. Core Prototype Flow + +Prototype path: + +1. Auth gate or signed-in workspace entry. +2. Dashboard with recent projects and diagrams. +3. Project creation or project selection. +4. Connection creation with DSN validation guidance. +5. Snapshot creation and busy/empty states. +6. ERD editor with search, auto-layout, table add/edit, relationship edit, + business grouping, cardinality recommendations, and exports. +7. Share/export modal with link creation, copy feedback, and DDL export. + +Interaction level: + +- First pass: clickable screen prototype with modal open/close states and + realistic data. +- Second pass: local frontend prototype or code PR only after the first pass is + reviewed. + +Deliverables: + +- Figma prototype links for the desktop flow. +- FigJam user-flow board for the core connection-to-share journey. +- Optional local coded prototype when a selected visual direction needs richer + interaction. +- First Figma pass complete: dashboard -> connection setup -> ERD editor -> + share/export, with clickable navigation on the main CTAs. + +### 4. Design QA Before Implementation + +Compare: + +- Existing screenshots in `docs/ui-ux`. +- Running local app screenshots at desktop width and a narrower viewport. +- Figma componentized output. + +QA checks: + +- Sidebar width, spacing, active states, and focus visibility. +- Dashboard card density and table scannability. +- Modal padding, title hierarchy, close button affordance, and form-first layout. +- ERD canvas framing, toolbar density, minimap placement, and empty/busy states. +- Table node readability for long names, comments, examples, badges, and indexes. +- Export/share copy, loading, error, and disabled states. +- Keyboard flow and dialog focus return. + +Deliverable: + +- `docs/ui-ux/design-qa-checklist.md` as the standing pre-implementation QA + gate. +- `docs/ui-ux/design-qa-report.md` with screenshots or Figma node links, + mismatches, decisions, and implementation-ready fixes. + +### 5. Implementation PR Path + +Only start code changes after the audit and Figma direction identify a specific +screen or interaction to update. + +Candidate frontend targets: + +- `frontend/src/App.tsx` for workspace flow, editor toolbar, empty states, and + share/export entry points. +- `frontend/src/styles.css` for design tokens, density, focus states, responsive + layout, and visual polish. +- `frontend/src/erd/TableNode.tsx` for ERD node readability, badges, indexes, + comments, and example values. +- `frontend/src/components/modals/*` for modal structure, copy, disabled states, + and accessibility refinements. + +Verification: + +- `cd frontend && npm run typecheck` +- `cd frontend && npm test` +- `cd frontend && npm run build` +- Browser screenshot check for dashboard, editor, and share/export modal. + +Deliverable: + +- A focused PR with the design decision, Figma/screenshot evidence, and test + output in the PR body. + +## Immediate Next Actions + +1. Review the Figma component sets and core flow prototype with product and + engineering stakeholders. +2. Choose the first implementation PR scope. The recommended first scope remains + share/export because it has a focused component and clear audit finding. +3. Capture the running local app into Figma when a browser-verifiable URL is + available, then compare it against the repository screenshots and current + Figma components. +4. Mirror the Figma token names into CSS custom properties before broad visual + implementation. +5. Use the implementation QA checklist before merging any UI code PR. + +## Open Inputs + +- First implementation priority: audit fixes, ERD editor, share/export, or + onboarding/dashboard. +- Whether narrow-width work should stay review/share-only or include full mobile + editing. diff --git a/docs/ui-ux/qa/2026-07-02-commercial-readiness-evidence-board.png b/docs/ui-ux/qa/2026-07-02-commercial-readiness-evidence-board.png new file mode 100644 index 00000000..1dfff682 Binary files /dev/null and b/docs/ui-ux/qa/2026-07-02-commercial-readiness-evidence-board.png differ diff --git a/docs/ui-ux/qa/2026-07-02-figma-share-export-modal.png b/docs/ui-ux/qa/2026-07-02-figma-share-export-modal.png new file mode 100644 index 00000000..e5e36571 Binary files /dev/null and b/docs/ui-ux/qa/2026-07-02-figma-share-export-modal.png differ diff --git a/docs/ui-ux/qa/2026-07-02-implementation-share-export-mobile.png b/docs/ui-ux/qa/2026-07-02-implementation-share-export-mobile.png new file mode 100644 index 00000000..78bc44e7 Binary files /dev/null and b/docs/ui-ux/qa/2026-07-02-implementation-share-export-mobile.png differ diff --git a/docs/ui-ux/qa/2026-07-02-implementation-share-export-modal.png b/docs/ui-ux/qa/2026-07-02-implementation-share-export-modal.png new file mode 100644 index 00000000..00619051 Binary files /dev/null and b/docs/ui-ux/qa/2026-07-02-implementation-share-export-modal.png differ diff --git a/docs/ui-ux/qa/2026-07-02-implementation-share-export-success-modal.png b/docs/ui-ux/qa/2026-07-02-implementation-share-export-success-modal.png new file mode 100644 index 00000000..69b14917 Binary files /dev/null and b/docs/ui-ux/qa/2026-07-02-implementation-share-export-success-modal.png differ diff --git a/docs/ui-ux/qa/2026-07-02-share-export-comparison.png b/docs/ui-ux/qa/2026-07-02-share-export-comparison.png new file mode 100644 index 00000000..1708eb3d Binary files /dev/null and b/docs/ui-ux/qa/2026-07-02-share-export-comparison.png differ diff --git a/docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/01-support-diagnostics-desktop.png b/docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/01-support-diagnostics-desktop.png new file mode 100644 index 00000000..d0ebe87b Binary files /dev/null and b/docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/01-support-diagnostics-desktop.png differ diff --git a/docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/02-support-diagnostics-mobile.png b/docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/02-support-diagnostics-mobile.png new file mode 100644 index 00000000..098910bb Binary files /dev/null and b/docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/02-support-diagnostics-mobile.png differ diff --git a/docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/03-demo-dashboard-commercial.png b/docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/03-demo-dashboard-commercial.png new file mode 100644 index 00000000..0a5bd272 Binary files /dev/null and b/docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/03-demo-dashboard-commercial.png differ diff --git a/docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/04-figma-support-diagnostics-audit-section.png b/docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/04-figma-support-diagnostics-audit-section.png new file mode 100644 index 00000000..67ce88a9 Binary files /dev/null and b/docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/04-figma-support-diagnostics-audit-section.png differ diff --git a/docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/05-figma-support-diagnostics-audit-section-fit.png b/docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/05-figma-support-diagnostics-audit-section-fit.png new file mode 100644 index 00000000..aec76378 Binary files /dev/null and b/docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/05-figma-support-diagnostics-audit-section-fit.png differ diff --git a/docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/README.md b/docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/README.md new file mode 100644 index 00000000..45a3d005 --- /dev/null +++ b/docs/ui-ux/qa/2026-07-02-support-diagnostics-audit/README.md @@ -0,0 +1,69 @@ +# Support Diagnostics Product Design Audit + +Date: 2026-07-02 + +Scope: commercial-readiness audit evidence for the support-operator billing +diagnostics path and the default buyer-demo dashboard. + +Capture source: + +- Local frontend demo server: `http://127.0.0.1:5174/` +- Demo mode: `VITE_DEMO_MODE=true` +- Support operator URL: `/?demo-support=operator` +- Browser automation: Playwright via the repository frontend dependency +- Figma design file: + +- Figma audit section: + +- Figma Code Connect: not used + +Evidence files: + +1. `01-support-diagnostics-desktop.png` + - Desktop operator view after looking up `customer-owner`. + - Health: improved for operator review. + - Evidence: support, billing portal, and reactivation destinations are shown + as named links with URL copy actions instead of raw full URLs. Recent + billing events include redacted provider metadata summaries such as invoice + and customer identifiers. +2. `02-support-diagnostics-mobile.png` + - Narrow viewport support diagnostics view. + - Health: stable and usable as a review/support fallback. + - Evidence: recent share links and billing events preserve key labels in + stacked rows, including redacted metadata evidence. +3. `03-demo-dashboard-commercial.png` + - Default commercial demo dashboard. + - Health: solid buyer-demo entry state. + - Risk: account status, billing status, and support escalation remain hidden + from the default dashboard unless the operator support mode is active. +4. `04-figma-support-diagnostics-audit-section.png` + - First Figma placement check. Rejected because uploaded image fills cropped + part of the evidence. +5. `05-figma-support-diagnostics-audit-section-fit.png` + - Accepted Figma placement check after switching image fills to `FIT`. + +Audit conclusion: + +- The read-only support diagnostics path is credible enough for paid-pilot + support demos when the operator is authorized. +- The default dashboard remains appropriate as the buyer entry point, but it + does not yet communicate billing or support trust signals. +- Narrow-width support diagnostics is now readable as a review/support fallback, + including a production-scale `stress-customer` fixture with long provider + events, contract IDs, plan names, timestamps, and redacted metadata evidence. + Real customer payloads still need browser-observed review before treating + mobile support diagnostics as the primary support workflow. + +Implementation evidence: + +- `frontend/src/api.ts` exposes demo-only support-operator state through + `?demo-support=operator`. +- `frontend/e2e/app-smoke.spec.ts` verifies that the support operator can open + the support diagnostics screen, look up a subject, inspect recent share links + and billing events, and see named support/billing links. +- `frontend/e2e/app-smoke.spec.ts` also verifies that `stress-customer` support + diagnostics keeps long billing evidence visible without overflowing the + support event rows at a 390px viewport. +- `backend/tests/test_billing_usage.py` verifies that support billing event + summaries include redacted metadata evidence without exposing raw provider + payloads. diff --git a/docs/ui-ux/qa/README.md b/docs/ui-ux/qa/README.md new file mode 100644 index 00000000..f08ce3de --- /dev/null +++ b/docs/ui-ux/qa/README.md @@ -0,0 +1,11 @@ +# UI/UX QA Evidence + +This folder stores visual QA captures used to compare Figma source designs against rendered frontend implementation states. + +## 2026-07-02 Share Export Modal + +- `2026-07-02-figma-share-export-modal.png`: Figma source node `29:143`. +- `2026-07-02-implementation-share-export-modal.png`: rendered default state at `1440x900`. +- `2026-07-02-implementation-share-export-success-modal.png`: rendered copied-link success state at `1440x900`. +- `2026-07-02-implementation-share-export-mobile.png`: rendered mobile state at `390x844 @ 2x`. +- `2026-07-02-share-export-comparison.png`: combined comparison evidence. diff --git a/docs/ui-ux/visual-regression-baseline.md b/docs/ui-ux/visual-regression-baseline.md new file mode 100644 index 00000000..ef731e6b --- /dev/null +++ b/docs/ui-ux/visual-regression-baseline.md @@ -0,0 +1,61 @@ +# Visual Regression Baseline Policy + +This policy defines when `pg-erd-cloud` visual snapshots can change. Visual +baselines are release gates, not cosmetic artifacts. A changed baseline is +acceptable only when the product change is intentional, reviewed, and backed by +browser evidence. + +## Covered Baselines + +The current CI gate runs `npm run test:visual` in Chromium demo mode and checks +these snapshots: + +| Surface | Browser | Viewport | Snapshot | +|---|---:|---:|---| +| Desktop ERD editor | Chromium | `1280x720` | `frontend/e2e/__screenshots__/chromium/visual-regression.spec/demo-editor.png` | +| Mobile review/editor entry | Chromium | `390x844` | `frontend/e2e/__screenshots__/chromium/visual-regression.spec/demo-editor-mobile.png` | +| First-run empty dashboard | Chromium | `1280x720` | `frontend/e2e/__screenshots__/chromium/visual-regression.spec/first-run-dashboard.png` | +| Share/export modal | Chromium | `1280x720` | `frontend/e2e/__screenshots__/chromium/visual-regression.spec/share-export-modal.png` | + +The mobile baseline is scoped to review and navigation confidence. Full mobile +ERD editing is not a release promise until Product Design defines that workflow. +The first-run baseline uses demo mode with `?demo-workspace=empty` to keep the +empty account onboarding state deterministic without touching production APIs. + +## Approval Requirements + +Every PR that updates a visual baseline must include: + +- The intentional product reason for the visual change. +- Source visual evidence: a Figma node, FigJam board, approved mockup, or + `docs/ui-ux` reference screenshot. +- Rendered browser evidence from the same state, including route, viewport, + browser, and screenshot path. +- A short note confirming that no text is clipped, no controls overlap, the + canvas shows diagram content or the intended empty state, and + toolbar/search/share controls remain visible. +- Approval from the product/design owner or release owner before merge. + +## Update Procedure + +1. Run `npm run test:visual` before accepting the new baseline and inspect the + generated diff or missing-snapshot failure. +2. Confirm the diff matches an intentional product/design change. +3. Regenerate snapshots with `npm run test:visual -- --update-snapshots`. +4. Re-run `npm run test:visual`. +5. Re-run the adjacent browser gates: `npm run test:e2e` and + `npm run test:a11y`. +6. Record the evidence in the PR body or a dated QA report under + `docs/ui-ux/qa/`. + +## No-Go Conditions + +Do not approve a visual baseline update when any of these are present: + +- Blank or mostly blank canvas without the intended empty-state panel. +- Missing primary navigation, editor toolbar, search, or share/export control. +- Korean or English labels clipped by buttons, panels, table nodes, or modals. +- Controls overlapping each other, the canvas, minimap, or modal content. +- Mobile viewport hiding the review/share path needed by non-editing users. +- Snapshot change caused only by nondeterminism, loading state drift, or test + data instability. diff --git a/frontend/e2e/__screenshots__/chromium/visual-regression.spec/demo-editor-mobile.png b/frontend/e2e/__screenshots__/chromium/visual-regression.spec/demo-editor-mobile.png new file mode 100644 index 00000000..6875decc Binary files /dev/null and b/frontend/e2e/__screenshots__/chromium/visual-regression.spec/demo-editor-mobile.png differ diff --git a/frontend/e2e/__screenshots__/chromium/visual-regression.spec/demo-editor.png b/frontend/e2e/__screenshots__/chromium/visual-regression.spec/demo-editor.png new file mode 100644 index 00000000..c5f4ac35 Binary files /dev/null and b/frontend/e2e/__screenshots__/chromium/visual-regression.spec/demo-editor.png differ diff --git a/frontend/e2e/__screenshots__/chromium/visual-regression.spec/first-run-dashboard.png b/frontend/e2e/__screenshots__/chromium/visual-regression.spec/first-run-dashboard.png new file mode 100644 index 00000000..e06e4cdc Binary files /dev/null and b/frontend/e2e/__screenshots__/chromium/visual-regression.spec/first-run-dashboard.png differ diff --git a/frontend/e2e/__screenshots__/chromium/visual-regression.spec/share-export-modal.png b/frontend/e2e/__screenshots__/chromium/visual-regression.spec/share-export-modal.png new file mode 100644 index 00000000..364f4400 Binary files /dev/null and b/frontend/e2e/__screenshots__/chromium/visual-regression.spec/share-export-modal.png differ diff --git a/frontend/e2e/__screenshots__/chromium/visual-regression.spec/support-diagnostics-mobile.png b/frontend/e2e/__screenshots__/chromium/visual-regression.spec/support-diagnostics-mobile.png new file mode 100644 index 00000000..2dd344e7 Binary files /dev/null and b/frontend/e2e/__screenshots__/chromium/visual-regression.spec/support-diagnostics-mobile.png differ diff --git a/frontend/e2e/__screenshots__/chromium/visual-regression.spec/support-diagnostics.png b/frontend/e2e/__screenshots__/chromium/visual-regression.spec/support-diagnostics.png new file mode 100644 index 00000000..e49efb2e Binary files /dev/null and b/frontend/e2e/__screenshots__/chromium/visual-regression.spec/support-diagnostics.png differ diff --git a/frontend/e2e/app-smoke.spec.ts b/frontend/e2e/app-smoke.spec.ts new file mode 100644 index 00000000..92080b09 --- /dev/null +++ b/frontend/e2e/app-smoke.spec.ts @@ -0,0 +1,85 @@ +import { expect, test } from '@playwright/test'; + +test('demo workspace loads, editor responds, and a screenshot is renderable', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByRole('heading', { name: '대시보드' })).toBeVisible(); + await expect(page.getByRole('navigation', { name: '주요 화면' })).toBeVisible(); + + await page + .getByRole('navigation', { name: '주요 화면' }) + .getByRole('button', { name: '편집기', exact: true }) + .click(); + + await expect(page.getByRole('toolbar', { name: 'ERD 캔버스 도구' })).toBeVisible(); + await expect(page.getByRole('button', { name: '공유 및 내보내기' })).toBeVisible(); + await expect(page.getByRole('searchbox', { name: '테이블 또는 컬럼 검색' })).toBeVisible(); + + const screenshot = await page.screenshot({ fullPage: false }); + expect(screenshot.byteLength).toBeGreaterThan(10_000); +}); + +test('demo support operator can inspect billing diagnostics', async ({ page }) => { + await page.goto('/?demo-support=operator'); + + const navigation = page.getByRole('navigation', { name: '주요 화면' }); + await expect(navigation.getByRole('button', { name: '지원' })).toBeVisible(); + await navigation.getByRole('button', { name: '지원' }).click(); + + await expect(page.getByRole('heading', { name: '지원 진단' })).toBeVisible(); + await page.getByRole('textbox', { name: '지원 진단 대상 subject' }).fill('customer-owner'); + await page.getByRole('button', { name: '조회' }).click(); + + await expect(page.getByText('demo-customer-user', { exact: true })).toBeVisible(); + await expect(page.getByText('서명 토큰')).toBeVisible(); + await expect(page.getByRole('link', { name: '지원센터 열기' })).toHaveAttribute( + 'href', + 'https://support.example.com/billing', + ); + await expect(page.getByRole('link', { name: '결제 포털 열기' })).toHaveAttribute( + 'href', + 'https://billing.example.com/customer/demo-customer-user', + ); + await expect(page.getByRole('table', { name: '최근 공유 링크' })).toBeVisible(); + await expect(page.getByText('demo-share-active-1')).toBeVisible(); + const billingEvents = page.getByRole('table', { name: '최근 결제 이벤트' }); + await expect(billingEvents).toBeVisible(); + await expect(billingEvents.getByRole('cell', { name: 'subscription.updated' })).toBeVisible(); + await expect(billingEvents.getByRole('cell', { name: /invoice_id=in_demo_001/ })).toBeVisible(); +}); + +test('demo support diagnostics keep long billing evidence readable', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/?demo-support=operator'); + + const navigation = page.getByRole('navigation', { name: '주요 화면' }); + await expect(navigation.getByRole('button', { name: '지원' })).toBeVisible(); + await navigation.getByRole('button', { name: '지원' }).click(); + + await page.getByRole('textbox', { name: '지원 진단 대상 subject' }).fill('stress-customer'); + await page.getByRole('button', { name: '조회' }).click(); + + const longEvent = 'contract.lifecycle.enterprise_plus_private_onprem_renewal_completed'; + const longPlan = 'onprem-enterprise-plus-krw-2b-evaluation-with-private-network-addon'; + const longEvidence = 'invoice_id=in_enterprise_private_network_202607'; + const billingEvents = page.getByRole('table', { name: '최근 결제 이벤트' }); + await expect(billingEvents.getByRole('cell', { name: longEvent })).toBeVisible(); + await expect(billingEvents.getByRole('cell', { name: longPlan })).toBeVisible(); + await expect(billingEvents.getByRole('cell', { name: new RegExp(longEvidence) })).toBeVisible(); + + const hasOverflow = await page + .locator('.supportEvents__row [role="cell"]') + .evaluateAll((cells) => + cells.some((cell) => { + const rect = cell.getBoundingClientRect(); + const table = cell.closest('.supportEvents'); + const tableRect = table?.getBoundingClientRect() ?? document.documentElement.getBoundingClientRect(); + return ( + rect.left < tableRect.left - 1 || + rect.right > tableRect.right + 1 || + cell.scrollWidth > Math.ceil(cell.clientWidth) + 1 + ); + }), + ); + expect(hasOverflow).toBe(false); +}); diff --git a/frontend/e2e/visual-regression.spec.ts b/frontend/e2e/visual-regression.spec.ts new file mode 100644 index 00000000..c6a12274 --- /dev/null +++ b/frontend/e2e/visual-regression.spec.ts @@ -0,0 +1,129 @@ +import { expect, test, type Page } from '@playwright/test'; + +const editorBaselines = [ + { + name: 'desktop', + screenshotName: 'demo-editor.png', + viewport: { width: 1280, height: 720 }, + }, + { + name: 'mobile review', + screenshotName: 'demo-editor-mobile.png', + viewport: { width: 390, height: 844 }, + }, +] as const; + +const supportBaselines = [ + { + name: 'desktop', + screenshotName: 'support-diagnostics.png', + viewport: { width: 1280, height: 900 }, + captureViewport: { width: 1280, height: 1500 }, + }, + { + name: 'mobile review', + screenshotName: 'support-diagnostics-mobile.png', + viewport: { width: 390, height: 844 }, + captureViewport: { width: 390, height: 3600 }, + }, +] as const; + +async function openDemoEditor(page: Page, viewport: { width: number; height: number }) { + await page.setViewportSize(viewport); + await page.goto('/'); + + await expect(page.getByRole('heading', { name: '대시보드' })).toBeVisible(); + await page + .getByRole('navigation', { name: '주요 화면' }) + .getByRole('button', { name: '편집기', exact: true }) + .click(); + + await expect(page.getByRole('toolbar', { name: 'ERD 캔버스 도구' })).toBeVisible(); + await expect(page.getByRole('searchbox', { name: '테이블 또는 컬럼 검색' })).toBeVisible(); + await expect(page.getByRole('button', { name: '공유 및 내보내기' })).toBeVisible(); +} + +async function openEmptyFirstRunDashboard(page: Page) { + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto('/?demo-workspace=empty'); + + await expect(page.getByRole('heading', { name: '대시보드' })).toBeVisible(); + await expect(page.getByRole('navigation', { name: '주요 화면' })).toBeVisible(); + await expect(page.getByLabel('작업 요약')).toBeVisible(); + await expect(page.getByText('아직 프로젝트가 없습니다. 편집기에서 프로젝트를 생성하세요.')).toBeVisible(); + await expect(page.getByText('아직 다이어그램 스냅샷이 없습니다. 편집기에서 데이터베이스를 역공학해 시작하세요.')).toBeVisible(); +} + +async function openDemoSupportDiagnostics( + page: Page, + viewport: { width: number; height: number }, +) { + await page.setViewportSize(viewport); + await page.goto('/?demo-support=operator'); + + const navigation = page.getByRole('navigation', { name: '주요 화면' }); + await expect(navigation.getByRole('button', { name: '지원' })).toBeVisible(); + await navigation.getByRole('button', { name: '지원' }).click(); + + await expect(page.getByRole('heading', { name: '지원 진단' })).toBeVisible(); + await page.getByRole('textbox', { name: '지원 진단 대상 subject' }).fill('customer-owner'); + await page.getByRole('button', { name: '조회' }).click(); + await expect(page.getByRole('table', { name: '최근 공유 링크' })).toBeVisible(); + await expect(page.getByRole('table', { name: '최근 결제 이벤트' })).toBeVisible(); +} + +for (const baseline of editorBaselines) { + test(`demo editor visual baseline remains stable on ${baseline.name}`, async ({ page }) => { + await openDemoEditor(page, baseline.viewport); + + await expect(page).toHaveScreenshot(baseline.screenshotName, { + animations: 'disabled', + fullPage: false, + maxDiffPixelRatio: 0.03, + }); + }); +} + +for (const baseline of supportBaselines) { + test(`support diagnostics visual baseline remains stable on ${baseline.name}`, async ({ page }) => { + await openDemoSupportDiagnostics(page, baseline.viewport); + await page.setViewportSize(baseline.captureViewport); + const pageHeight = await page.evaluate(() => + Math.max(document.body.scrollHeight, document.documentElement.scrollHeight), + ); + expect(pageHeight).toBeLessThanOrEqual(baseline.captureViewport.height); + + await expect(page).toHaveScreenshot(baseline.screenshotName, { + animations: 'disabled', + fullPage: false, + maxDiffPixelRatio: 0.04, + }); + }); +} + +test('first-run empty dashboard visual baseline remains stable on desktop', async ({ page }) => { + await openEmptyFirstRunDashboard(page); + + await expect(page).toHaveScreenshot('first-run-dashboard.png', { + animations: 'disabled', + fullPage: false, + maxDiffPixelRatio: 0.03, + }); +}); + +test('share export modal visual baseline remains stable on desktop', async ({ page }) => { + await openDemoEditor(page, { width: 1280, height: 720 }); + + await page.getByRole('button', { name: '공유 및 내보내기' }).click(); + const dialog = page.getByRole('dialog', { name: '공유 및 내보내기' }); + + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('heading', { name: '공유 링크' })).toBeVisible(); + await expect(dialog.getByRole('heading', { name: '내보내기 산출물' })).toBeVisible(); + await expect(dialog.getByRole('button', { name: '링크 만들기' })).toBeVisible(); + + await expect(dialog).toHaveScreenshot('share-export-modal.png', { + animations: 'disabled', + maxDiffPixelRatio: 0.06, + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 07942ec3..f008b82e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "react-dom": "^19.2.7" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -443,6 +444,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", @@ -2015,6 +2032,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4c1d15d7..80f5c444 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,10 @@ "build": "tsc -b && vite build", "typecheck": "tsc --noEmit", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run src", + "test:a11y": "vitest run src/App.accessibility.test.tsx src/components/modals/DialogAccessibility.test.tsx", + "test:e2e": "playwright test e2e/app-smoke.spec.ts", + "test:visual": "playwright test e2e/visual-regression.spec.ts" }, "dependencies": { "@xyflow/react": "^12.11.1", @@ -19,6 +22,7 @@ "react-dom": "^19.2.7" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..b29c0504 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test'; + +const port = Number(process.env.PLAYWRIGHT_PORT ?? 5174); +const baseURL = `http://127.0.0.1:${port}`; + +export default defineConfig({ + testDir: './e2e', + snapshotPathTemplate: + '{testDir}/__screenshots__/{projectName}/{testFileBaseName}/{arg}{ext}', + timeout: 30_000, + expect: { + timeout: 5_000, + }, + use: { + baseURL, + trace: 'retain-on-failure', + }, + webServer: { + command: `npm run dev -- --host 127.0.0.1 --port ${port} --strictPort`, + url: baseURL, + reuseExistingServer: false, + env: { + VITE_DEMO_MODE: 'true', + }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/frontend/src/App.accessibility.test.tsx b/frontend/src/App.accessibility.test.tsx new file mode 100644 index 00000000..b2f949b7 --- /dev/null +++ b/frontend/src/App.accessibility.test.tsx @@ -0,0 +1,212 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import App from './App'; +import { getBillingSupportAccount, getMe } from './api'; + +globalThis.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; + +vi.mock('./api', () => ({ + getMe: vi.fn().mockResolvedValue({ + subject: 'test-user', + display_name: 'Test User', + user_account_uuid: 'test-user-uuid', + support_operator: false + }), + listProjects: vi.fn().mockResolvedValue([]), + listConnections: vi.fn().mockResolvedValue([]), + listSnapshots: vi.fn().mockResolvedValue([]), + createProject: vi.fn(), + createConnection: vi.fn(), + createSnapshot: vi.fn(), + createShareLink: vi.fn(), + getBillingSupportAccount: vi.fn(), + getSnapshot: vi.fn(), +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('App accessibility smoke', () => { + it('exposes navigation, skip link, main landmark, and editor toolbar names', async () => { + const user = userEvent.setup(); + render(); + + const navigation = await screen.findByRole('navigation', { name: '주요 화면' }); + const skipLink = screen.getByRole('link', { name: '본문 바로가기' }); + expect(skipLink).toHaveAttribute('href', '#main'); + expect(screen.getByRole('main')).toHaveAttribute('id', 'main'); + + const nav = within(navigation); + expect(nav.getByRole('button', { name: '대시보드' })).toHaveAttribute( + 'aria-current', + 'page', + ); + expect(nav.queryByRole('button', { name: '지원' })).not.toBeInTheDocument(); + + await user.click(nav.getByRole('button', { name: '편집기' })); + + const toolbar = await screen.findByRole('toolbar', { name: 'ERD 캔버스 도구' }); + const toolbarQueries = within(toolbar); + expect( + toolbarQueries.getByRole('searchbox', { name: '테이블 또는 컬럼 검색' }), + ).toBeInTheDocument(); + expect( + toolbarQueries.getByRole('button', { name: 'ERD 자동 정렬' }), + ).toBeInTheDocument(); + expect( + toolbarQueries.getByRole('button', { name: '공유 및 내보내기' }), + ).toBeInTheDocument(); + }); + + it('explains account deactivation with reactivation and support links', async () => { + vi.mocked(getMe).mockRejectedValueOnce( + Object.assign(new Error('getMe failed: 403'), { + status: 403, + accountStatus: 'deactivated', + accountReactivationUrl: 'https://billing.example.com/reactivate', + billingSupportUrl: 'https://support.example.com', + }), + ); + + render(); + + expect( + await screen.findByRole('heading', { + name: '계정이 비활성화되었습니다', + }), + ).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('결제 또는 계약 상태'); + expect(screen.getByRole('link', { name: '계정 재활성화' })).toHaveAttribute( + 'href', + 'https://billing.example.com/reactivate', + ); + expect(screen.getByRole('link', { name: '지원팀에 문의' })).toHaveAttribute( + 'href', + 'https://support.example.com', + ); + }); + + it('shows read-only support diagnostics for support operators', async () => { + const user = userEvent.setup(); + vi.mocked(getMe).mockResolvedValueOnce({ + subject: 'support-operator', + display_name: 'Support Operator', + user_account_uuid: 'support-user-uuid', + support_operator: true, + }); + vi.mocked(getBillingSupportAccount).mockResolvedValueOnce({ + subject: 'customer-owner', + user_account_uuid: 'customer-user-uuid', + account_status: 'active', + license_mode: 'required', + license_verifier: 'signed_token', + billing_portal_url: 'https://billing.example.com', + billing_support_url: 'https://support.example.com', + account_reactivation_url: 'https://billing.example.com/reactivate', + project_count: 2, + seat_count: 5, + connection_count: 3, + snapshot_count: 8, + share_link_count: 4, + active_share_link_count: 1, + billing_entitlement: { + plan: 'enterprise', + seat_count: 25, + source_provider: 'stripe', + source_provider_event_id: 'evt_1', + source_event_type: 'subscription.updated', + source_occurred_at: '2026-07-02T00:00:00Z', + }, + llm_usage_current_month: { + scope: 'account', + month: '2026-07', + request_count: 42, + success_count: 39, + failure_count: 3, + quota_exceeded_count: 1, + input_chars: 12345, + output_chars: 6789, + }, + recent_share_links: [ + { + share_link_uuid: 'share-link-1', + project_space_uuid: 'project-1', + permission_kind: 'viewer', + status: 'active', + expires_at: '2026-07-09T00:00:00Z', + created_at: '2026-07-02T00:00:00Z', + }, + ], + recent_billing_events: [ + { + billing_event_uuid: 'event-1', + provider: 'stripe', + provider_event_id: 'evt_1', + event_type: 'subscription.updated', + target_plan: 'enterprise', + status: 'recorded', + occurred_at: '2026-07-02T00:00:00Z', + received_at: '2026-07-02T01:00:00Z', + metadata_summary: [ + { key: 'invoice_id', value: 'in_123' }, + { key: 'api_key', value: '[redacted]' }, + ], + }, + ], + }); + + render(); + + const navigation = await screen.findByRole('navigation', { name: '주요 화면' }); + await user.click(within(navigation).getByRole('button', { name: '지원' })); + + expect( + await screen.findByRole('heading', { name: '지원 진단' }), + ).toBeInTheDocument(); + + await user.type( + screen.getByRole('textbox', { name: '지원 진단 대상 subject' }), + 'customer-owner', + ); + await user.click(screen.getByRole('button', { name: '조회' })); + + expect(getBillingSupportAccount).toHaveBeenCalledWith('customer-owner'); + expect(await screen.findByText('customer-user-uuid')).toBeInTheDocument(); + expect(screen.getByText('서명 토큰')).toBeInTheDocument(); + expect(screen.getAllByText('enterprise')).not.toHaveLength(0); + expect(screen.getByText('25')).toBeInTheDocument(); + expect(screen.getByText('stripe / subscription.updated')).toBeInTheDocument(); + const llmUsage = screen.getByLabelText('이번 달 LLM 사용량 지표'); + expect(within(llmUsage).getByText('2026-07')).toBeInTheDocument(); + expect(within(llmUsage).getByText('42')).toBeInTheDocument(); + expect(within(llmUsage).getByText('12,345 / 6,789')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '지원센터 열기' })).toHaveAttribute( + 'href', + 'https://support.example.com', + ); + expect(screen.getByRole('link', { name: '결제 포털 열기' })).toHaveAttribute( + 'href', + 'https://billing.example.com', + ); + expect(screen.getByRole('link', { name: '재활성화 열기' })).toHaveAttribute( + 'href', + 'https://billing.example.com/reactivate', + ); + expect(screen.getByRole('table', { name: '최근 공유 링크' })).toBeInTheDocument(); + expect(screen.getByText('share-link-1')).toBeInTheDocument(); + expect(screen.getAllByText('활성')).not.toHaveLength(0); + expect(screen.getByRole('table', { name: '최근 결제 이벤트' })).toBeInTheDocument(); + expect(screen.getByText('subscription.updated')).toBeInTheDocument(); + expect(screen.getByText(/invoice_id=in_123/)).toBeInTheDocument(); + expect(screen.getByText(/api_key=\[redacted\]/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e99700c5..8868156e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,6 +25,7 @@ import { import { getMe, + getBillingSupportAccount, createShareLink, createConnection, createProject, @@ -58,6 +59,7 @@ import { } from "./erd/export"; import { exportMermaid } from "./erd/mermaid"; import { GRID_COLUMNS, GRID_X_GAP, GRID_Y_GAP } from "./erd/layoutConstants"; +import type { BillingSupportAccount, CurrentUser } from "./api"; import type { Connection, Project, Snapshot, SnapshotDetail } from "./types"; const TERMINAL_SNAPSHOT_STATUSES = new Set([ @@ -68,14 +70,23 @@ const TERMINAL_SNAPSHOT_STATUSES = new Set([ const SUPPORTED_DSN_PROTOCOLS = new Set(["postgres:", "postgresql:", "snowflake:"]); -type CurrentUser = { - subject: string; - display_name: string | null; +type AuthNotice = { + title: string; + message: string; + accountReactivationUrl?: string; + billingSupportUrl?: string; }; -type WorkspaceView = "dashboard" | "projects" | "diagrams" | "editor"; +type AccountAwareError = { + status?: unknown; + accountStatus?: unknown; + accountReactivationUrl?: unknown; + billingSupportUrl?: unknown; +}; + +type WorkspaceView = "dashboard" | "projects" | "diagrams" | "editor" | "support"; -const workspaceNavItems: Array<{ id: WorkspaceView; label: string }> = [ +const baseWorkspaceNavItems: Array<{ id: WorkspaceView; label: string }> = [ { id: "dashboard", label: "대시보드" }, { id: "projects", label: "프로젝트" }, { id: "diagrams", label: "다이어그램" }, @@ -95,17 +106,95 @@ function isSupportedConnectionDsn(value: string): boolean { } } +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function accountAwareError(error: unknown): AccountAwareError { + return error && typeof error === "object" ? (error as AccountAwareError) : {}; +} + +function authNoticeFromError(error: unknown): AuthNotice { + const details = accountAwareError(error); + const status = typeof details.status === "number" ? details.status : null; + const accountStatus = optionalString(details.accountStatus); + const accountReactivationUrl = optionalString(details.accountReactivationUrl); + const billingSupportUrl = optionalString(details.billingSupportUrl); + + if (status === 403 && accountStatus === "deactivated") { + return { + title: "계정이 비활성화되었습니다", + message: + "결제 또는 계약 상태 때문에 계정 접근이 중지되었습니다. 재활성화 또는 지원 채널을 통해 상태를 확인하세요.", + accountReactivationUrl, + billingSupportUrl, + }; + } + + if (status === 401) { + return { + title: "인증이 필요합니다", + message: "세션이 만료되었거나 로그인되지 않았습니다. 다시 로그인한 뒤 시도하세요.", + }; + } + + if (status === 403) { + return { + title: "접근 권한이 없습니다", + message: + "현재 계정에 이 작업공간 접근 권한이 없습니다. 조직 관리자 또는 지원팀에 권한을 확인하세요.", + billingSupportUrl, + }; + } + + return { + title: "인증 상태를 확인할 수 없습니다", + message: "로그인 상태를 확인할 수 없습니다. 잠시 후 다시 시도하거나 지원팀에 문의하세요.", + billingSupportUrl, + }; +} + function strengthLabel(strength: CardinalityStrength): string { if (strength === "recommended") return "추천"; if (strength === "consider") return "검토"; return "보류"; } +function accountStatusLabel(status: BillingSupportAccount["account_status"]): string { + if (status === "active") return "활성"; + if (status === "deactivated") return "비활성"; + return "미확인"; +} + +function shareLinkStatusLabel( + status: BillingSupportAccount["recent_share_links"][number]["status"], +): string { + if (status === "active") return "활성"; + return "만료"; +} + +function licenseVerifierLabel( + verifier: BillingSupportAccount["license_verifier"], +): string { + if (verifier === "signed_token") return "서명 토큰"; + if (verifier === "static_key") return "정적 키"; + if (verifier === "static_key_and_signed_token") return "정적 키 + 서명 토큰"; + return "없음"; +} + +function billingMetadataSummaryLabel( + metadataSummary: + BillingSupportAccount["recent_billing_events"][number]["metadata_summary"], +): string { + if (!metadataSummary.length) return "없음"; + return metadataSummary.map((item) => `${item.key}=${item.value}`).join(", "); +} + export default function App() { const [activeView, setActiveView] = useState("dashboard"); const [me, setMe] = useState(null); const [isAuthLoading, setIsAuthLoading] = useState(true); - const [authError, setAuthError] = useState(null); + const [authError, setAuthError] = useState(null); const [projects, setProjects] = useState([]); const [projectName, setProjectName] = useState("demo"); const [selectedProjectId, setSelectedProjectId] = useState( @@ -138,6 +227,7 @@ export default function App() { > | null>(null); const copyFeedbackTimeoutRef = useRef(null); const shareCopyFeedbackTimeoutRef = useRef(null); + const supportCopyFeedbackTimeoutRef = useRef(null); const dsnInputRef = useRef(null); const [isLayouting, setIsLayouting] = useState(false); @@ -149,6 +239,11 @@ export default function App() { const [isCreatingShareLink, setIsCreatingShareLink] = useState(false); const [isShareLinkCopied, setIsShareLinkCopied] = useState(false); const [shareLinkError, setShareLinkError] = useState(null); + const [supportSubject, setSupportSubject] = useState(""); + const [supportAccount, setSupportAccount] = useState(null); + const [isSupportLookupLoading, setIsSupportLookupLoading] = useState(false); + const [supportLookupError, setSupportLookupError] = useState(null); + const [copiedSupportUrlLabel, setCopiedSupportUrlLabel] = useState(null); const [editingEdge, setEditingEdge] = useState(null); const [editingNode, setEditingNode] = useState | null>(null); @@ -177,6 +272,13 @@ export default function App() { > | null>(null); const nodeTypes = useMemo(() => ({ tableNode: TableNode }), []); + const workspaceNavItems = useMemo( + () => + me?.support_operator + ? [...baseWorkspaceNavItems, { id: "support" as const, label: "지원" }] + : baseWorkspaceNavItems, + [me?.support_operator], + ); const normalizedNodeSearch = nodeSearch.trim().toLocaleLowerCase(); const searchMatchedNodeIds = useMemo(() => { if (!normalizedNodeSearch) return new Set(); @@ -225,6 +327,9 @@ export default function App() { if (shareCopyFeedbackTimeoutRef.current !== null) { window.clearTimeout(shareCopyFeedbackTimeoutRef.current); } + if (supportCopyFeedbackTimeoutRef.current !== null) { + window.clearTimeout(supportCopyFeedbackTimeoutRef.current); + } }; }, []); @@ -257,7 +362,7 @@ export default function App() { Promise.all([getMe(), listProjects()]) .then(([m, p]) => { if (!isCurrent) return; - setMe({ subject: m.subject, display_name: m.display_name }); + setMe(m); setProjects(p); setSelectedProjectId(p[0]?.project_space_uuid || null); }) @@ -268,7 +373,7 @@ export default function App() { setSelectedProjectId(null); setConnections([]); setSelectedConnId(null); - setAuthError(String(e)); + setAuthError(authNoticeFromError(e)); }) .finally(() => { if (isCurrent) setIsAuthLoading(false); @@ -279,6 +384,12 @@ export default function App() { }; }, []); + useEffect(() => { + if (activeView === "support" && !me?.support_operator) { + setActiveView("dashboard"); + } + }, [activeView, me?.support_operator]); + useEffect(() => { if (!selectedProjectId) { setConnections([]); @@ -604,6 +715,66 @@ export default function App() { } }, [shareLinkUrl]); + const onLookupSupportAccount = useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + const subject = supportSubject.trim(); + if (!subject || isSupportLookupLoading) return; + + setIsSupportLookupLoading(true); + setSupportLookupError(null); + setCopiedSupportUrlLabel(null); + try { + setSupportAccount(await getBillingSupportAccount(subject)); + } catch { + setSupportAccount(null); + setSupportLookupError("지원 진단 정보를 불러오지 못했습니다."); + } finally { + setIsSupportLookupLoading(false); + } + }, + [isSupportLookupLoading, supportSubject], + ); + + const onCopySupportUrl = useCallback(async (label: string, url: string) => { + try { + await navigator.clipboard.writeText(url); + setSupportLookupError(null); + setCopiedSupportUrlLabel(label); + + if (supportCopyFeedbackTimeoutRef.current !== null) { + window.clearTimeout(supportCopyFeedbackTimeoutRef.current); + } + + supportCopyFeedbackTimeoutRef.current = window.setTimeout(() => { + setCopiedSupportUrlLabel(null); + supportCopyFeedbackTimeoutRef.current = null; + }, 2000); + } catch { + setCopiedSupportUrlLabel(null); + setSupportLookupError(`${label} URL 복사에 실패했습니다.`); + } + }, []); + + const renderSupportUrl = useCallback( + (label: string, actionLabel: string, url: string | null) => { + if (!url) return 미설정; + + const isCopied = copiedSupportUrlLabel === label; + return ( + + + {actionLabel} + + + + ); + }, + [copiedSupportUrlLabel, onCopySupportUrl], + ); + function onDownloadSvg() { downloadText( "pg-erd-diagram.svg", @@ -975,10 +1146,23 @@ export default function App() { } if (!me) { + const authNotice = + authError ?? + authNoticeFromError(new Error("Sign in before managing database metadata.")); return (
-

Authentication required

-

{authError ?? "Sign in before managing database metadata."}

+

{authNotice.title}

+

{authNotice.message}

+ {authNotice.accountReactivationUrl || authNotice.billingSupportUrl ? ( +
+ {authNotice.accountReactivationUrl ? ( + 계정 재활성화 + ) : null} + {authNotice.billingSupportUrl ? ( + 지원팀에 문의 + ) : null} +
+ ) : null}
); } @@ -1327,6 +1511,261 @@ export default function App() { }} /> + ) : activeView === "support" ? ( +
+
+
+

지원 진단

+

+ 계정 상태, 사용량, 라이선스 검증 방식, LLM 비용 사용량, 최근 공유 + 링크와 결제 이벤트를 read-only로 확인합니다. +

+
+
+ +
+ +
+ setSupportSubject(event.currentTarget.value)} + placeholder="customer-owner" + /> + +
+
+ + {supportLookupError ? ( +
+ {supportLookupError} +
+ ) : null} + + {supportAccount ? ( + <> +
+
+ 프로젝트 + {supportAccount.project_count} +
+
+ 시트 + {supportAccount.seat_count} +
+
+ 연결 + {supportAccount.connection_count} +
+
+ 스냅샷 + {supportAccount.snapshot_count} +
+
+ 공유 링크 + {supportAccount.share_link_count} +
+
+ 활성 공유 + {supportAccount.active_share_link_count} +
+
+ +
+
+

계정 운영 정보

+
+
+
+
Subject
+
{supportAccount.subject}
+
+
+
계정 UUID
+
{supportAccount.user_account_uuid || "미생성"}
+
+
+
계정 상태
+
{accountStatusLabel(supportAccount.account_status)}
+
+
+
라이선스 모드
+
{supportAccount.license_mode}
+
+
+
검증 방식
+
{licenseVerifierLabel(supportAccount.license_verifier)}
+
+
+
계약 플랜
+
{supportAccount.billing_entitlement.plan || "근거 없음"}
+
+
+
계약 시트
+
+ {supportAccount.billing_entitlement.seat_count?.toLocaleString() || + "근거 없음"} +
+
+
+
계약 근거
+
+ {supportAccount.billing_entitlement.source_provider_event_id + ? `${supportAccount.billing_entitlement.source_provider || "unknown"} / ${supportAccount.billing_entitlement.source_event_type || "unknown"}` + : "근거 없음"} +
+
+
+
지원 URL
+
{renderSupportUrl("지원", "지원센터 열기", supportAccount.billing_support_url)}
+
+
+
포털 URL
+
{renderSupportUrl("포털", "결제 포털 열기", supportAccount.billing_portal_url)}
+
+
+
재활성화 URL
+
{renderSupportUrl("재활성화", "재활성화 열기", supportAccount.account_reactivation_url)}
+
+
+
+ +
+
+

이번 달 LLM 사용량

+
+
+
+ + {supportAccount.llm_usage_current_month.month} +
+
+ LLM 요청 + + {supportAccount.llm_usage_current_month.request_count.toLocaleString()} + +
+
+ 성공 + + {supportAccount.llm_usage_current_month.success_count.toLocaleString()} + +
+
+ 실패 + + {supportAccount.llm_usage_current_month.failure_count.toLocaleString()} + +
+
+ 쿼터 초과 + + {supportAccount.llm_usage_current_month.quota_exceeded_count.toLocaleString()} + +
+
+ 입력/출력 문자 + + {supportAccount.llm_usage_current_month.input_chars.toLocaleString()} + {" / "} + {supportAccount.llm_usage_current_month.output_chars.toLocaleString()} + +
+
+
+ +
+
+ +
+ {supportAccount.recent_share_links.length ? ( +
+
+ Project + Link + Status + Expires +
+ {supportAccount.recent_share_links.map((link) => ( +
+ + {link.project_space_uuid} + + + {link.share_link_uuid} + + + {shareLinkStatusLabel(link.status)} + + + {link.expires_at || "만료 없음"} + +
+ ))} +
+ ) : ( +
기록된 공유 링크가 없습니다.
+ )} +
+ +
+
+

최근 결제 이벤트

+
+ {supportAccount.recent_billing_events.length ? ( +
+
+ Provider + Event + Plan + Evidence + Received +
+ {supportAccount.recent_billing_events.map((event) => ( +
+ {event.provider} + {event.event_type} + {event.target_plan || "없음"} + + {billingMetadataSummaryLabel(event.metadata_summary)} + + {event.received_at} +
+ ))} +
+ ) : ( +
기록된 결제 이벤트가 없습니다.
+ )} +
+ + ) : ( +
지원할 계정 subject를 입력해 진단 정보를 조회하세요.
+ )} +
) : (
+
-
-
-
- -

읽기 가능한 스냅샷과 내보내기 API로 연결되는 프로젝트 링크입니다.

-
- -
+
+
+ +

+ 팀원이 검토할 수 있는 API 기반 프로젝트 링크를 생성합니다. 복사 + 피드백은 작업 후에도 확인할 수 있게 유지합니다. +

+ + - {shareLinkUrl ? ( -
- +
+ {shareLinkUrl ? ( + + ) : ( + + )}
- ) : ( - - 프로젝트가 선택되면 서버에서 새 공유 링크를 발급할 수 있습니다. - - )} +
- {shareLinkError ? ( -
{shareLinkError}
- ) : null} -
+
+

내보내기 산출물

+

+ 공유 링크와 즉시 다운로드되는 캔버스 산출물을 분리해 무엇이 + 프로젝트 밖으로 나가는지 명확히 합니다. +

-
-
-
-

DDL 내보내기

-

현재 캔버스의 테이블, 컬럼, 관계를 SQL DDL 텍스트로 복사합니다.

+
+ {artifacts.map((artifact) => ( +
+
+ {artifact.label} + {artifact.description} +
+ +
+ ))}
- -
+
+
- {hasDdlExport ? ( -