from datetime import date, datetime from decimal import Decimal from typing import Any from uuid import UUID from pydantic import BaseModel, Field, field_validator # ── Provider (admin catalog) ── class ApiKeyProviderCreate(BaseModel): name: str = Field(..., min_length=1, max_length=128) description: str | None = Field(None, max_length=2000) is_active: bool = True sort_order: int = 0 class ApiKeyProviderUpdate(BaseModel): name: str | None = Field(None, min_length=1, max_length=128) description: str | None = Field(None, max_length=2000) is_active: bool | None = None sort_order: int | None = None class ApiKeyProviderResponse(BaseModel): id: UUID name: str description: str | None = None is_active: bool sort_order: int created_at: datetime updated_at: datetime # ── Assignment (employee key record) ── class ApiKeyRequestSubmit(BaseModel): """Employee submits an API key request (goes to Feishu approval).""" provider_id: UUID project_name: str = Field(..., min_length=1, max_length=256) # Upper-bound the free-text reason — written to a TEXT column with # no service-level cap and renderered into the Feishu approval card # body. 2000 chars matches the rest of the platform's request-form # text caps. (R1 sweep finding: Class C borderline — Pydantic had # ``min_length`` only.) reason: str = Field(..., min_length=1, max_length=2000) # Round-27 pre-launch (2026-05-25): hard cap of $50,000 so a # pathological submit (`99_999_999.99`) cannot mint a cloud # resource whose budget alert won't fire until ~$80M spent. The # 80% / 100% budget_check cron uses the value as-is; without a cap # the alert is effectively disabled for fat-fingered values. Admin # MUST raise via platform setting or direct DB write for legitimate # high-budget requests — explicit, audited, not employee-controlled. monthly_budget_usd: Decimal = Field(..., ge=20, le=50_000) valid_from: date valid_until: date # ``expected_usage`` is shown in the Feishu approval body and stored # to TEXT; same cap as ``reason``. expected_usage: str | None = Field(default=None, max_length=2000) answers: dict[str, Any] = Field(default_factory=dict) @field_validator("valid_from") @classmethod def validate_valid_from_not_too_old(cls, v: date) -> date: """Reject ``valid_from`` more than 1 day in the past. Round-20 audit (A2F5): accepting an arbitrary past ``valid_from`` caused the auto-grant flow to mint cloud resources (Bedrock AK / Bailian AK / AOAI account) for keys that would immediately fall under ``expired`` on the next expiry cron tick — wasting one IAM/RAM user / one resource group per stale request and spamming the assignee with a "key ready" then "key expired" pair. One day of slack covers timezone skew between client + server. """ from datetime import timedelta as _td if v < date.today() - _td(days=1): raise ValueError( "valid_from cannot be more than 1 day in the past — " "set it to today or a future date", ) return v @field_validator("valid_until") @classmethod def validate_max_one_year(cls, v: date, info) -> date: valid_from = info.data.get("valid_from") if valid_from and v: delta = v - valid_from if delta.days > 366: raise ValueError("Usage period must not exceed 1 year") if delta.days < 0: raise ValueError("valid_until must be after valid_from") return v class ApiKeyAssignmentResponse(BaseModel): id: UUID provider_id: UUID provider_name: str | None = None assigned_to: UUID assigned_to_name: str | None = None assigned_to_email: str | None = None assigned_to_employee_no: str | None = None assigned_to_employment_status: str | None = None status: str request_number: str project_name: str reason: str monthly_budget_usd: Decimal expected_usage: str | None = None # Key is always masked unless explicitly revealed key_masked: str | None = None has_key: bool = False valid_from: date | None = None valid_until: date | None = None days_until_expiry: int | None = None expiry_alert: str | None = None # "30d" | "20d" | "10d" | "expired" | None extended_count: int = 0 approval_instance_code: str | None = None detail: dict[str, Any] | None = None # IAM / RAM binding (cloud auto-grant providers only). iam_user_id: UUID | None = None iam_user_arn: str | None = None iam_user_name: str | None = None # Joined from cmdb_iam_users for display. iam_integration_id: str | None = None iam_integration_name: str | None = None # Joined for display. # Cloud-scope binding — used when the chargeback target is a # subscription / sub-account rather than (or in addition to) a # specific IAM user. Surfaces on the admin UI so the AI-spending # dashboard can attribute the scope's bill back to the holder. cloud_scope_id: str | None = None cloud_scope_display_name: str | None = None # Per-assignment budget-alert recipient override (migration 146). # See ``ApiKeyAssignment.notify_recipients`` for the tri-state # semantics (None = default, [] = suppress, [...] = override list). notify_recipients: list[str] | None = None # ── Revocation metadata (migration 183) ── # NULL on rows that are not in status='revoked' (or on pre-PR # historical revoked rows that weren't backfilled with the full # set). See ApiKeyAssignment for the per-field semantics. revoked_at: datetime | None = None revoked_by: UUID | None = None revoked_by_name: str | None = None # joined display for admin table revoke_reason_code: str | None = None revoke_reason_note: str | None = None revoke_actor_role: str | None = None # Ownership ancestry (migration 184, PR2). NULL on a row that has # never been transferred. ``transferred_from_name`` is the joined # display label so the admin table / "我的 API Key" can show "from # Alice" without an extra fetch. transferred_from: UUID | None = None transferred_from_name: str | None = None # Shared-access roster (migration 200): list of teammates the # Owner has granted read access to. Always present in the response # (empty list when no members) so the frontend can render the # "Members" section consistently without a separate fetch. # Each entry is ``{id, display_name, work_email, added_at}``. members: list[dict[str, Any]] = [] # ── Credential rotation (migration 401) ── # ``rotation_status`` is ``"pending_retire"`` while a rotation is in # flight (new credential live, old one awaiting retire), else None. # ``rotation_retire_at`` is the UTC moment the old credential retires. # Drives the "轮换中 · 旧 key X 停用" badge and the complete / cancel # actions. Both None on keys that aren't mid-rotation. rotation_status: str | None = None rotation_retire_at: datetime | None = None # Viewer's relationship to this assignment as resolved server-side: # ``owner`` (assigned_to == viewer), ``member`` (viewer is on the # roster), ``admin`` (asset_admin / it_admin / management / auditor), # ``none`` (theoretical — the row wouldn't appear in a list response). # Drives the frontend's "manage members" gate and the identity Tag # on the list row. viewer_role: str | None = None created_at: datetime updated_at: datetime class ApiKeyIamRebindRequest(BaseModel): """Body for ``PATCH /apikeys/assignments/{id}/iam-binding``. The admin chooses the new target IAM user from the existing ``cmdb_iam_users`` catalog. ``iam_user_id=None`` clears the binding (used when the IAM user has been deleted cloud-side and the admin is documenting the orphan state). ``reason`` ≥ 4 chars surfaces in audit so we can explain rebinds during compliance review. Cloud-scope binding (``cloud_scope_id``) is the parallel path for chargeback targets that aren't an IAM user — Azure OpenAI subscriptions in particular. Either / both / neither may be set; the AI-spending dashboard reads whichever is populated to attribute usage cost. Pass ``omit`` (don't include the field) to leave existing value untouched; pass ``null`` to clear. Note: rebinding does NOT rotate keys, change cloud-side IAM, or revoke the previous IAM user. Use the dedicated CMDB grant / revoke flows for those — this endpoint only updates Helix-side book-keeping. """ iam_user_id: UUID | None = None cloud_scope_id: str | None = Field( None, description=( "FK to cmdb_cloud_scopes.id. Pass null to clear, omit to " "leave unchanged. Supports binding to Azure subscriptions " "/ AWS Org sub-accounts whose entire bill counts as " "chargeback target for this assignment." ), ) reason: str = Field(..., min_length=4, max_length=500) class ApiKeyFillRequest(BaseModel): """Admin fills in the actual key value after approval. ``overwrite`` is required to rotate the key on an already-ACTIVE row that has a stamped ``key_value`` — defence against an admin's accidental double-click silently rotating the live credential. Default False; the service returns 409 ``KEY_ALREADY_FILLED`` when the guard would trip. """ key_value: str = Field(..., min_length=1) overwrite: bool = False class ApiKeyExtendRequest(BaseModel): """Employee extends their key validity.""" new_valid_until: date @field_validator("new_valid_until") @classmethod def validate_future(cls, v: date) -> date: if v <= date.today(): raise ValueError("new_valid_until must be in the future") return v class ApiKeyRotateRequest(BaseModel): """Body for ``POST /apikeys/assignments/{id}/rotate``. Self-service credential rotation for cloud-auto-granted keys (AWS Bedrock / Aliyun Bailian / Azure OpenAI). A fresh credential is minted and goes live immediately; the old one keeps working for ``grace_days`` (dual-active), then the retire cron tears it down. ``grace_days`` is one of ``0`` (立即 — hard cut, no overlap), ``3``, ``7`` (default) or ``14``. The upper bound caps the dual-active exposure window; the allowed set is validated against ``rotation.ALLOWED_GRACE_DAYS`` at the service layer too. """ grace_days: int = Field( default=7, description="Grace days before the old credential retires: 0 / 3 / 7 / 14.", ) @field_validator("grace_days") @classmethod def validate_grace(cls, v: int) -> int: from app.domains.apikey.rotation import ALLOWED_GRACE_DAYS if v not in ALLOWED_GRACE_DAYS: raise ValueError( f"grace_days must be one of {list(ALLOWED_GRACE_DAYS)}", ) return v class ApiKeyRevokeRequest(BaseModel): """Admin-revoke body. ``reason_code`` is the new structured bucket added in PR1 of the API-Key lifecycle closure; ``reason`` (legacy free-text) maps to the row's ``revoke_reason_note`` column. Both stay optional on the wire so existing admin clients (the table-row StopOutlined button) keep working — the service layer defaults ``reason_code`` to ``admin_revoke`` when omitted, so the row column is never NULL on a fresh revoke regardless of which client called. """ reason: str | None = None reason_code: str | None = Field( None, description=( "Optional admin bucket: admin_revoke | leaked | compliance | " "other. Defaults to 'admin_revoke' when omitted." ), ) @field_validator("reason_code") @classmethod def validate_reason_code(cls, v: str | None) -> str | None: if v is None: return v # Imported lazily to avoid circular ref via service.py from app.domains.apikey.service import ADMIN_REVOKE_REASON_CODES if v not in ADMIN_REVOKE_REASON_CODES: raise ValueError( f"reason_code must be one of {sorted(ADMIN_REVOKE_REASON_CODES)}", ) return v class ApiKeyTransferRequestSubmit(BaseModel): """Body for ``POST /apikeys/assignments/{id}/transfer-request``. PR2 of the API-Key lifecycle closure (2026-05-19): an employee hands ownership of a still-needed key to a colleague without invalidating the credential. Unlike release, transfer does NOT tear down the cloud-side IAM user / RAM user / AOAI account — the credential keeps working through the handover, so production code keeps running. Authz on the service layer: owner-only. Recipient must be an active person who is NOT the initiator (enforced by the shared ``OffboardingService.create_transfer`` we delegate to). ``note`` is the free-text explanation that ends up in ``AssetTransferRequest.reason`` and the notification card the recipient sees in ``/my/transfers``. """ recipient_person_id: UUID note: str | None = Field(None, max_length=500) class ApiKeyReleaseRequest(BaseModel): """Body for ``POST /apikeys/assignments/{id}/release-request``. Employee asks their direct manager to revoke an API key they no longer need. The employee can't directly revoke — production code may depend on the credential, so a manager gate prevents an impulsive "I don't use this" click from cratering a service. Approval is **Helix-internal** (NOT a Feishu approval template): submitting this row inserts an ``api_key_release_requests`` row in ``pending`` and pushes a Feishu / email notification card to the manager linking to the Helix Web UI approval page. The manager clicks 同意 / 拒绝 inside Helix; no Feishu approval card, no callback round-trip. ``reason_code`` is required (enum); ``reason_note`` is required ONLY when ``reason_code == 'other'`` (any other code, the note is optional context). 1000-char cap is the application-layer bound; the DB column is TEXT. """ reason_code: str = Field(..., max_length=32) reason_note: str | None = Field(None, max_length=1000) @field_validator("reason_code") @classmethod def validate_reason_code(cls, v: str) -> str: from app.domains.apikey.service import RELEASE_REASON_CODES if v not in RELEASE_REASON_CODES: raise ValueError( f"reason_code must be one of {sorted(RELEASE_REASON_CODES)}", ) return v @field_validator("reason_note") @classmethod def validate_note_required_for_other( cls, v: str | None, info, ) -> str | None: if info.data.get("reason_code") == "other": if not v or not v.strip(): raise ValueError( "reason_note is required when reason_code='other'", ) return v class AssignmentUpdate(BaseModel): """Body for ``PATCH /apikeys/assignments/{id}``. Self-service / admin edit for two fields on an existing assignment: * ``monthly_budget_usd`` — the per-month USD cap that the ``apikey.budget_check`` cron alerts against. Same ``ge=20`` bar as :class:`ApiKeyRequestSubmit` so an admin can't accidentally configure a cap below the platform floor. * ``notify_recipients`` — list of ``identity_persons.id`` (UUID strings) that receive 80% / 100% budget alerts. Tri-state: - ``None`` (field omitted OR explicit null): use default "applicant + their HRIS manager". - ``[]`` (empty list): admin override "no recipients" — suppress budget alerts entirely. Valid but dangerous; the audit row captures the change so compliance can spot a silenced key during a budget review. - ``[, ...]``: exactly these persons receive alerts. The service validates every id exists in ``identity_persons`` and rejects the request with a 400 listing missing ids if any are unknown. Authz lives in the service: actor must be admin OR the assignment's applicant (``ApiKeyAssignment.assigned_to``). """ monthly_budget_usd: Decimal | None = Field(None, ge=20) # Pydantic note: ``UUID`` would be the cleanest type, but # canonicalising to ``str`` here keeps the JSON column round-trip # stable across MySQL / TiDB (both store JSON arrays of strings # without coercing back to int) and matches the model column type # (``list[str] | None``). Service layer validates each entry is a # well-formed UUID and exists in ``identity_persons``. notify_recipients: list[str] | None = None class AssignmentTransferOwner(BaseModel): """Body for ``POST /apikeys/assignments/{id}/transfer-owner``. Admin-direct ownership transfer used by the reconciliation ownership/leaver-residual lenses to re-home a key that points at a departed or wrong owner — distinct from the self-service ``transfer-request`` (peer-accept) flow. Reuses the same ``apply_transfer`` rewrite path (local flip + cost-allocation rule swap + AWS owner-tag rewrite), so the recipient must be an active employee (enforced there). """ assigned_to: UUID class ApiKeyMemberAddRequest(BaseModel): """Body for ``POST /apikeys/assignments/{id}/members``. Owner adds a teammate to the shared-access roster. ``person_id`` must reference an existing :class:`~app.identity.person_models.IdentityPerson` in active employment (``employment_status != 'left'`` and ``deleted_at IS NULL``); the service validates this and 400s otherwise so the Owner doesn't silently grant a leaver read access. Self-add is rejected at the service layer — an applicant is already the Owner, and adding themselves as a member would render as a duplicate row in the panel. """ person_id: UUID class ApiKeyMemberResponse(BaseModel): """One row of an API Key's member roster. Hydrated by :meth:`ApiKeyService.list_members`. ``added_by_name`` is denormalised on response (one fetch per row) — small N (members per key are typically < 10) so an N+1 lookup is acceptable. """ id: UUID assignment_id: UUID person_id: UUID display_name: str | None = None work_email: str | None = None added_by: UUID | None = None added_by_name: str | None = None added_at: datetime class ApiKeyReleaseDecisionBody(BaseModel): """Body for ``POST /apikeys/release-requests/{id}/approve|reject``. Approval / rejection in the Helix-internal release flow. The approver is identified by the JWT (``current_user``); the path parameter identifies the release request. The decision note is optional on approve and on reject — UX guidance asks the manager to leave one on reject so the requester sees *why*. 1000-char cap is the application-layer bound; the DB column is TEXT. """ decision_note: str | None = Field(None, max_length=1000) class ApiKeyApplyApprovalResponse(BaseModel): """One pending AI API-Key *apply* (申请) request that is currently waiting on the caller's approval in Feishu. Unlike a release (which Helix decides internally), the apply approval is decided in the Feishu「审批」app — Helix can only SURFACE it so the approver knows it exists and is waiting on them. The decision itself still happens in Feishu (per the no-third-party-approval decision), so this carries no approve/reject affordance; ``feishu_instance_code`` is the live instance the approver acts on in Feishu. """ # The ApiKeyAssignment id (the apply row). id: UUID request_number: str | None = None requester_id: UUID requester_display_name: str | None = None requester_email: str | None = None # 工号 + 雇佣状态 so the approver UI flags a departed requester. requester_employee_no: str | None = None requester_employment_status: str | None = None provider_name: str | None = None project_name: str | None = None reason: str | None = None monthly_budget_usd: Decimal | None = None expected_usage: str | None = None submitted_at: datetime # The live Feishu approval instance the caller approves in飞书审批. feishu_instance_code: str | None = None # The unified employee_request id, so the frontend can open the same # request-detail drawer (live approval flow) the requester sees. employee_request_id: UUID | None = None class ApiKeyReleaseRequestResponse(BaseModel): """Serialised :class:`ApiKeyReleaseRequest` row for the API. Returned by the submit / approve / reject / list endpoints. The embedded ``assignment_summary`` denormalises the few assignment columns the approver UI needs (provider name, project, requester display) so the frontend can render the approval card without a second round-trip. """ id: UUID assignment_id: UUID requester_id: UUID approver_id: UUID | None = None reason_code: str reason_note: str | None = None status: str decision_note: str | None = None previous_status: str requested_at: datetime responded_at: datetime | None = None created_at: datetime updated_at: datetime # Hydrated fields (set by the service layer, not stored). All are # optional in the schema so a stripped-down list endpoint can omit # them. requester_display_name: str | None = None requester_email: str | None = None requester_employee_no: str | None = None requester_employment_status: str | None = None approver_display_name: str | None = None provider_name: str | None = None project_name: str | None = None request_number: str | None = None