diff --git a/CHANGELOG.md b/CHANGELOG.md index cbc0a1ae..aebe5a90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Unreleased +# v1.1.0 +## Features + +### TFE admin identity (SAML / SCIM) +* Added ``client.admin`` nested namespace exposing three TFE-only services: ``client.admin.saml_settings`` (read, update, revoke_idp_cert), ``client.admin.scim_settings`` (read, update, delete), and ``client.admin.scim_tokens`` (list, create, read, delete). All endpoints return ``pytfe.errors.NotFound`` on HCP Terraform (SaaS) — verified live against ``app.terraform.io``. +* Added models: ``AdminSAMLSettings`` / ``AdminSAMLSettingsUpdateOptions``, ``AdminSCIMSettings`` / ``AdminSCIMSettingsUpdateOptions``, ``AdminSCIMToken`` / ``AdminSCIMTokenCreateOptions``, plus ``SAMLProviderType`` and ``SAMLSignatureMethod`` enums. +* ``AdminSCIMSettingsUpdateOptions`` distinguishes "field unset" from "field explicitly set to None" for ``site_admin_group_scim_id``. Pass ``None`` to send JSON ``null`` (unlinking the SCIM site-admin group); omit the kwarg entirely to leave the server value untouched. The omit-vs-explicit-null distinction is preserved end-to-end via a custom ``to_payload()`` that inspects Pydantic's ``model_fields_set``. +* Added typed exceptions ``InvalidSAMLProviderTypeError``, ``InvalidSCIMTokenIDError``, ``RequiredSCIMTokenDescriptionError``. +* The transport-level redacting logger now redacts the wire-format ``private-key`` field (with hyphen) in addition to the existing ``private_key`` (with underscore), so SAML SP private keys cannot leak via ``PYTFE_LOG=debug``. X.509 certificate fields (``idp-cert``, ``certificate``, ``old-idp-cert``) are intentionally NOT redacted because they're public material by design. + +### GitHub App installation discovery +* Added ``client.github_app_installations`` resource with ``list`` (supports ``filter[name]`` and ``filter[installation_id]``) and ``read`` methods for looking up GitHub App installations the authenticated user can see. Returns ``GitHubAppInstallation`` records carrying both the HCP-side ``id`` (``ghain-...``) and the GitHub-side numeric ``installation_id``. The actual GitHub App authorisation flow happens through the HCP Terraform UI; this resource is the discovery surface workspace/stack/registry-module VCS configuration consumes. +* Added model ``GitHubAppInstallation``, ``GitHubAppInstallationListOptions``, ``GitHubAppInstallationType``. +* Added typed exception ``InvalidGitHubAppInstallationIDError``. ## Bug Fixes ### Pagination diff --git a/README.md b/README.md index 072071b2..2ef03ab6 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ and upstream HCP Terraform API docs. |---|---| | Configure the SDK | [Authentication](./docs/authentication.md), [Pagination](./docs/pagination.md), [Logging](./docs/LOGGING.md) | | API guides | [API index](./docs/api/index.md), [Workspaces](./docs/api/workspaces.md), [Runs/plans/applies](./docs/api/runs-plans-applies.md), [State versions](./docs/api/state-versions.md) | -| Scenario guides | [API-driven run](./docs/scenarios/api-driven-run.md), [State management](./docs/scenarios/state-management.md), [Migrate workspaces and state](./docs/scenarios/migrate-workspaces-and-state.md), [Team access onboarding](./docs/scenarios/team-access-onboarding.md), [No-code provisioning](./docs/scenarios/no-code-provisioning.md) | +| Scenario guides | [API-driven run](./docs/scenarios/api-driven-run.md), [State management](./docs/scenarios/state-management.md), [Migrate workspaces and state](./docs/scenarios/migrate-workspaces-and-state.md), [Team access onboarding](./docs/scenarios/team-access-onboarding.md), [No-code provisioning](./docs/scenarios/no-code-provisioning.md), [TFE identity bootstrap](./docs/scenarios/tfe-identity-bootstrap.md), [TFE admin bootstrap](./docs/scenarios/tfe-admin-bootstrap.md) | | Operations guides | [Troubleshooting](./docs/troubleshooting.md), [Errors](./docs/errors.md), [Terraform Enterprise](./docs/terraform-enterprise.md) | | Contribute to the SDK | [CONTRIBUTING](./docs/CONTRIBUTING.md), [ITERATORS](./docs/ITERATORS.md), [MODELS](./docs/MODELS.md), [RESOURCE](./docs/RESOURCE.md) | diff --git a/docs/api/admin-identity.md b/docs/api/admin-identity.md new file mode 100644 index 00000000..7481464c --- /dev/null +++ b/docs/api/admin-identity.md @@ -0,0 +1,270 @@ +# Admin identity (SAML / SCIM) and GitHub App installations + +pyTFE exposes the Terraform Enterprise admin identity APIs (SAML +settings, SCIM settings, SCIM tokens) under a nested namespace, +`client.admin.*`, and the read-only GitHub App installation discovery +API as a top-level `client.github_app_installations` resource. + +| Service | TFE-only | Purpose | +|---|---|---| +| `client.admin.saml_settings` | yes | Read / update the org's SAML config; revoke previous IdP cert | +| `client.admin.scim_settings` | yes | Enable / pause SCIM, manage site-admin group mapping | +| `client.admin.scim_tokens` | yes | List / create / read / delete SCIM provisioning tokens | +| `client.github_app_installations` | no | Look up GitHub App installations visible to the caller | + +Upstream docs: + +- SAML settings: https://developer.hashicorp.com/terraform/enterprise/api-docs/admin/settings +- SCIM settings: https://developer.hashicorp.com/terraform/enterprise/api-docs/admin/scim-settings +- SCIM tokens: https://developer.hashicorp.com/terraform/enterprise/api-docs/admin/scim-tokens +- GitHub App installations: https://developer.hashicorp.com/terraform/enterprise/api-docs/github-app-installations + +Examples: + +- [`admin_identity.py`](../../examples/admin_identity.py) +- [`github_app_installations.py`](../../examples/github_app_installations.py) + +## Why the nested namespace + +The TFE admin APIs require **site-admin** permission, are not available +on HCP Terraform (SaaS), and live under a distinct `/api/v2/admin/...` +URL prefix. Grouping them under `client.admin.*` makes that boundary +visible at the call site — when you see `client.admin.foo()` you know +this is admin work, not a regular organisation operation. Calls against +HCP Terraform return `404`, surfaced as `pytfe.errors.NotFound`. + +## SAML settings + +Singleton resource: the organisation has exactly one SAML config. + +| Method | Purpose | +|---|---| +| `client.admin.saml_settings.read()` | Read current SAML config. | +| `client.admin.saml_settings.update(options)` | Partial update — only fields you set are sent. | +| `client.admin.saml_settings.revoke_idp_cert()` | Promote the new IdP cert and clear the old one. | + +```python +from pytfe import TFEClient +from pytfe.models import ( + AdminSAMLSettingsUpdateOptions, + SAMLProviderType, + SAMLSignatureMethod, +) + +client = TFEClient() + +# Read +saml = client.admin.saml_settings.read() +print(saml.enabled, saml.provider_type, saml.sso_endpoint_url) + +# Update (partial — only the listed fields are sent) +saml = client.admin.saml_settings.update( + AdminSAMLSettingsUpdateOptions( + enabled=True, + idp_cert="-----BEGIN CERTIFICATE-----\n...", + sso_endpoint_url="https://idp.example.com/sso", + slo_endpoint_url="https://idp.example.com/slo", + provider_type=SAMLProviderType.OKTA, + authn_requests_signed=True, + signature_signing_method=SAMLSignatureMethod.SHA256, + ) +) +``` + +### Rotating the IdP certificate + +```python +# 1. Push the new cert via update. The old cert stays in place during +# the rotation window so in-flight sessions keep working. +client.admin.saml_settings.update( + AdminSAMLSettingsUpdateOptions( + idp_cert="-----BEGIN CERTIFICATE-----\nNEW...", + ) +) + +# 2. Drain in-flight SSO sessions / verify the new cert works. + +# 3. Revoke the old cert. +client.admin.saml_settings.revoke_idp_cert() +``` + +### Sensitive fields + +`private_key` (wire name: `private-key`) is sensitive. The transport- +level debug logger redacts that key in both `snake_case` and +hyphenated forms before anything reaches the log. Certificate material +(`idp-cert`, `certificate`, `old-idp-cert`) is **not** redacted — +those are public X.509 blobs by design. + +## SCIM settings + +Singleton resource. Three operations: read, update (PATCH), delete. + +| Method | Purpose | +|---|---| +| `client.admin.scim_settings.read()` | Read current SCIM config. | +| `client.admin.scim_settings.update(options)` | Partial update; see omit-vs-null note below. | +| `client.admin.scim_settings.delete()` | Disable SCIM. PATCH cannot set `enabled=False`; use this instead. | + +```python +from pytfe.models import AdminSCIMSettingsUpdateOptions + +scim = client.admin.scim_settings.read() +print(scim.enabled, scim.paused, scim.site_admin_group_display_name) + +# Pause SCIM provisioning without disabling it +client.admin.scim_settings.update(AdminSCIMSettingsUpdateOptions(paused=True)) + +# Disable SCIM entirely (note: does NOT revoke site-admin access +# already granted by SCIM to existing users) +client.admin.scim_settings.delete() +``` + +### The omit-vs-explicit-null rule for `site_admin_group_scim_id` + +This field has three meaningful states the SDK preserves end-to-end: + +| Caller intent | How to express it | What goes on the wire | +|---|---|---| +| Don't touch the server value | Omit the kwarg entirely | Field is not in the request body | +| Set the mapping to a specific group | `site_admin_group_scim_id="g-1"` | `{"site-admin-group-scim-id": "g-1"}` | +| Unlink the SCIM site-admin group | `site_admin_group_scim_id=None` | `{"site-admin-group-scim-id": null}` | + +Pydantic's normal `exclude_none=True` flattens "omit" and "explicit +None" together. To preserve the distinction this update options model +overrides serialization via `to_payload()`, which inspects +`model_fields_set` to tell the two cases apart. You call the resource +method the same way; the distinction is handled internally. + +```python +# Omit — server keeps existing mapping +client.admin.scim_settings.update( + AdminSCIMSettingsUpdateOptions(paused=False) +) + +# Explicit None — unlink the SCIM group from site-admin +client.admin.scim_settings.update( + AdminSCIMSettingsUpdateOptions(site_admin_group_scim_id=None) +) +``` + +## SCIM tokens + +| Method | Purpose | +|---|---| +| `client.admin.scim_tokens.list()` | Iterate existing SCIM tokens (without their plaintext values). | +| `client.admin.scim_tokens.create(options)` | Mint a new SCIM token. The plaintext value is on the response — capture it now. | +| `client.admin.scim_tokens.read(scim_token_id)` | Read a single token's metadata. | +| `client.admin.scim_tokens.delete(scim_token_id)` | Revoke a SCIM token. | + +```python +from pytfe.models import AdminSCIMTokenCreateOptions + +# Mint a token — capture .token from the response, you won't see it again +new = client.admin.scim_tokens.create( + AdminSCIMTokenCreateOptions(description="okta-scim-bot") +) +print(new.id, new.token) # token is None on every subsequent read + +for tok in client.admin.scim_tokens.list(): + print(tok.id, tok.description, tok.last_used_at) + +client.admin.scim_tokens.delete("at-...") +``` + +Two operational notes worth knowing: + +- The `description` field is technically optional in the upstream API, + but the SDK rejects an empty description at the resource layer + (raises `RequiredSCIMTokenDescriptionError`). Audit logs are + unreadable without one. +- The DELETE path is `/api/v2/admin/scim-tokens/{id}` (the admin + namespace), not the generic `/api/v2/authentication-tokens/{id}` + path used by other token types. + +## GitHub App installations + +Read-only lookup. Use these endpoints to discover the +`github-app-installation-id` value that workspace, stack, and +registry-module VCS configuration takes. + +| Method | Purpose | +|---|---| +| `client.github_app_installations.list(options=None)` | Iterate installations visible to the caller. | +| `client.github_app_installations.read(github_app_installation_id)` | Read one by HCP-side ID. | + +```python +from pytfe.models import GitHubAppInstallationListOptions + +# Find all installations +for app in client.github_app_installations.list(): + print(app.id, app.name, app.installation_id, app.installation_type) + +# Filter by GitHub org / login name +for app in client.github_app_installations.list( + GitHubAppInstallationListOptions(name="my-org") +): + print(app.id, app.installation_url) + +# Filter by GitHub-side numeric installation ID (NOT the HCP `id`) +for app in client.github_app_installations.list( + GitHubAppInstallationListOptions(installation_id=54810170) +): + print(app.id) + +# Read a specific installation by its HCP-side ID +app = client.github_app_installations.read("ghain-abc123") +``` + +Two things worth pinning explicitly: + +- The read URL uses the singular path segment: `/api/v2/github-app/installation/{id}`, + not the plural `installations`. The list URL uses the plural. This + is the upstream contract — the SDK mirrors it. +- `installation_id` (the GitHub-side numeric ID) is distinct from `id` + (HCP Terraform's internal string ID, e.g. `ghain-...`). VCS-config + fields like `github-app-installation-id` use the HCP-side `id`, not + the numeric GitHub-side installation ID. + +## SMTP settings + +Singleton resource. Same admin-only requirements as SAML/SCIM. + +| Method | Purpose | +|---|---| +| `client.admin.smtp_settings.read()` | Read current SMTP config (no password). | +| `client.admin.smtp_settings.update(options)` | Partial update; `password` and `test_email_address` are write-only. | + +```python +from pytfe.models import AdminSMTPSettingsUpdateOptions, SMTPAuthType + +# Read +smtp = client.admin.smtp_settings.read() +print(smtp.enabled, smtp.host, smtp.port, smtp.auth) + +# Update (also sends a test email if test_email_address is set) +client.admin.smtp_settings.update( + AdminSMTPSettingsUpdateOptions( + enabled=True, + host="smtp.example.com", + port=587, + sender="noreply@example.com", + auth=SMTPAuthType.LOGIN, + username="smtp-bot", + password="set-by-secret-manager", + test_email_address="ops@example.com", + ) +) +``` + +The `auth` field accepts `SMTPAuthType.NONE`, `PLAIN`, or `LOGIN`. +`password` is sensitive — the transport logger redacts it in debug +output. `test_email_address` is a write-only signal: when supplied on +update, TFE sends a verification email to that address and the field is +not returned on read. + +## Token requirements + +- SAML / SCIM / SCIM tokens / SMTP: TFE site-admin user token. +- GitHub App installations: any user token; the response is scoped to + what that user can see. diff --git a/docs/api/index.md b/docs/api/index.md index f61ea95e..92df2a93 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -94,6 +94,20 @@ column. | `client.explorer` | `Explorer` | query and saved-view helpers | [explorer.py](../../examples/explorer.py) | [Explorer](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/explorer) | | `client.stacks` | `Stacks` | `list`, `read`, `create`, `update`, `delete`, `force_delete`, VCS fetch | [stack.py](../../examples/stack.py) | [Stacks](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks) | | `client.stack_configurations` | `StackConfigurations` | `list`, `read`, `create` | [stack_configuration.py](../../examples/stack_configuration.py) | [Stacks](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks) | +| `client.github_app_installations` | `GitHubAppInstallations` | `list`, `read` | [github_app_installations.py](../../examples/github_app_installations.py) | [GitHub App installations](https://developer.hashicorp.com/terraform/enterprise/api-docs/github-app-installations) | +| `client.organization_token_ttl_policies` | `OrganizationTokenTTLPolicies` | `list`, `update`, `reset_to_defaults` | [org_token_ttl.py](../../examples/org_token_ttl.py) | [Org token TTL settings](https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/organizations/settings#api-tokens) | + +## TFE admin (site-admin only) + +These endpoints require TFE site-admin permission and return `404` on +HCP Terraform (SaaS). + +| Client attribute | Resource class | Common methods | Example | Upstream API docs | +|---|---|---|---|---| +| `client.admin.saml_settings` | `_AdminSAMLSettings` | `read`, `update`, `revoke_idp_cert` | [admin_identity.py](../../examples/admin_identity.py) | [SAML settings](https://developer.hashicorp.com/terraform/enterprise/api-docs/admin/settings) | +| `client.admin.scim_settings` | `_AdminSCIMSettings` | `read`, `update`, `delete` | [admin_identity.py](../../examples/admin_identity.py) | [SCIM settings](https://developer.hashicorp.com/terraform/enterprise/api-docs/admin/scim-settings) | +| `client.admin.scim_tokens` | `_AdminSCIMTokens` | `list`, `create`, `read`, `delete` | [admin_identity.py](../../examples/admin_identity.py) | [SCIM tokens](https://developer.hashicorp.com/terraform/enterprise/api-docs/admin/scim-tokens) | +| `client.admin.smtp_settings` | `_AdminSMTPSettings` | `read`, `update` | [admin_smtp.py](../../examples/admin_smtp.py) | [SMTP settings](https://developer.hashicorp.com/terraform/enterprise/api-docs/admin/settings) | ## Focused guides @@ -105,3 +119,5 @@ column. - [policies.md](policies.md) - [run-tasks.md](run-tasks.md) - [no-code-provisioning.md](no-code-provisioning.md) +- [admin-identity.md](admin-identity.md) +- [organization-defaults-and-token-ttl.md](organization-defaults-and-token-ttl.md) diff --git a/docs/api/organization-defaults-and-token-ttl.md b/docs/api/organization-defaults-and-token-ttl.md new file mode 100644 index 00000000..6175184e --- /dev/null +++ b/docs/api/organization-defaults-and-token-ttl.md @@ -0,0 +1,210 @@ +# Organisation defaults and API-token TTL policy + +Two closely-related per-organisation knobs that both live alongside +the existing `client.organizations` resource: + +- **Default execution mode + default agent pool** — what new workspaces + inherit. Exposed via three focused methods on `client.organizations`: + `read_default_settings`, `update_default_settings`, + `reset_default_settings`. +- **API-token max TTL** — how long org/team/user/audit tokens minted in + the organisation are allowed to live. Exposed on a dedicated resource: + `client.organization_token_ttl_policies`. + +Both are available on HCP Terraform and on Terraform Enterprise. Neither +requires site-admin permissions; org-owner permissions are sufficient. + +Upstream docs: + +- Organisations API: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/organizations +- Organisation settings (max TTL): https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/organizations/settings#api-tokens + +Examples: + +- [`admin_smtp.py`](../../examples/admin_smtp.py) (SMTP — TFE-only; not relevant here but in the same bootstrap scenario) +- [`org_token_ttl.py`](../../examples/org_token_ttl.py) + +## Default execution mode + default agent pool + +| Method | Purpose | +|---|---| +| `client.organizations.read_default_settings(org)` | Read default execution mode + default agent pool. | +| `client.organizations.update_default_settings(org, options)` | Partial update — see omit-vs-explicit-null rule below. | +| `client.organizations.reset_default_settings(org)` | Convenience: reset to `remote` execution and clear the default agent pool. | + +```python +from pytfe import TFEClient +from pytfe.models import OrganizationDefaultSettingsUpdateOptions + +client = TFEClient() + +# Read +defaults = client.organizations.read_default_settings("my-org") +print(defaults.default_execution_mode, defaults.default_agent_pool_id) + +# Switch to agent execution and pin the default pool +client.organizations.update_default_settings( + "my-org", + OrganizationDefaultSettingsUpdateOptions( + default_execution_mode="agent", + default_agent_pool_id="apool-abc123", + ), +) + +# Reset to remote, clearing the agent pool +client.organizations.reset_default_settings("my-org") +``` + +### Cross-field validation + +`OrganizationDefaultSettingsUpdateOptions` rejects at construction time +the combination "specify a pool id while explicitly asking for a +non-agent execution mode": + +```python +# Raises pydantic.ValidationError immediately — no API call. +OrganizationDefaultSettingsUpdateOptions( + default_execution_mode="remote", + default_agent_pool_id="apool-abc123", +) +``` + +This mirrors the upstream rule and surfaces the mistake locally rather +than as an opaque server-side 422. + +### Omit vs explicit `None` for `default_agent_pool_id` + +Like SCIM settings, the agent pool field distinguishes three caller +intents end-to-end: + +| Caller intent | How to express it | What goes on the wire | +|---|---|---| +| Don't touch the server value | Omit the kwarg entirely | Field is not in the request body | +| Set the pool to a specific id | `default_agent_pool_id="apool-1"` | `{"default-agent-pool-id": "apool-1"}` | +| Clear the pool | `default_agent_pool_id=None` | `{"default-agent-pool-id": null}` | + +The `to_payload()` method on the options inspects +`model_fields_set` to preserve this distinction — Pydantic's +`exclude_none=True` would otherwise flatten "omit" and "explicit None" +together. + +### What about the broader `client.organizations.update`? + +The existing `OrganizationUpdateOptions` has also been fixed (this same +release) so its `default_execution_mode`, `default_agent_pool_id`, and +`max_ttl_enabled` fields now serialise with the correct hyphenated JSON +wire names. Previously they were emitted as snake_case and silently +ignored by the server. If you were calling `client.organizations.update` +with those fields and seeing no effect, this fixes it. + +## API-token TTL policy + +The org enforces a per-token-type maximum lifetime when +`max_ttl_enabled=True` on the parent organisation. The per-token-type +values live on a separate resource: + +| Method | Purpose | +|---|---| +| `client.organization_token_ttl_policies.list(org)` | Iterate current policies. | +| `client.organization_token_ttl_policies.update(org, options)` | PATCH a partial set; at least one field required. | +| `client.organization_token_ttl_policies.reset_to_defaults(org)` | Reset all four token types to the documented 2-year default. | + +```python +from pytfe.models import OrgTokenTTLPolicyUpdateOptions, DEFAULT_MAX_TTL_MS + +# List +for policy in client.organization_token_ttl_policies.list("my-org"): + print(policy.token_type, policy.max_ttl_ms) + +# Update some token types — accepts integers (raw ms) OR duration strings +client.organization_token_ttl_policies.update( + "my-org", + OrgTokenTTLPolicyUpdateOptions( + organization="2y", # duration string + team="30d", # duration string + user=DEFAULT_MAX_TTL_MS, # raw ms + # audit_trails omitted -> server keeps existing value + ), +) + +# Reset everything to the 2-year default +client.organization_token_ttl_policies.reset_to_defaults("my-org") +``` + +### Duration parser + +`parse_ttl_to_ms()` accepts the same suffixes the Terraform provider +does: + +| Suffix | Meaning | +|---|---| +| `ms` | milliseconds | +| `s` | seconds | +| `m` | minutes | +| `h` | hours | +| `d` | days | +| `w` | weeks (7 days) | +| `mo` | months (approximated as 30 days) | +| `y` | years (365 days) | + +```python +from pytfe.models import parse_ttl_to_ms + +parse_ttl_to_ms("2y") # -> 63_072_000_000 +parse_ttl_to_ms("30d") # -> 2_592_000_000 +parse_ttl_to_ms("6mo") # -> 15_552_000_000 +parse_ttl_to_ms("1h") # -> 3_600_000 +``` + +Use exact day counts (e.g. `"90d"`) when you need precision; months are +approximated as 30 days. + +### Important: `audit_trails` token type spelling + +The TTL policy API uses `audit_trails` (with an UNDERSCORE) for the +audit-trail policy entry. This is **deliberately different** from the +audit-trail token *creation* endpoint elsewhere in the API which uses +`audit-trails` (with a HYPHEN). The `TokenPolicyType.AUDIT_TRAILS` enum +member preserves the TTL-specific spelling exactly: + +```python +from pytfe.models import TokenPolicyType +TokenPolicyType.AUDIT_TRAILS.value # -> "audit_trails" +``` + +If you copy a token-type string from another part of the API into a TTL +policy call, the server will reject it. The SDK enforces the correct +value at construction time via the enum. + +### Empty-update guard + +Building an `OrgTokenTTLPolicyUpdateOptions` with no fields and calling +`update()` raises `pytfe.errors.RequiredFieldMissing` **before** any +HTTP request is made: + +```python +client.organization_token_ttl_policies.update( + "my-org", + OrgTokenTTLPolicyUpdateOptions(), # no fields +) +# RequiredFieldMissing: OrgTokenTTLPolicyUpdateOptions requires at +# least one of organization, team, user, or audit_trails to be set. +``` + +This guards against accidental no-op calls that would otherwise hit the +server and either silently succeed (changing nothing) or fail with a +shape error. + +## Operational notes + +- **Pair `max_ttl_enabled` with policies.** The TTL policy values are + only enforced when the org's `max_ttl_enabled` is true. Flip that on + with `client.organizations.update(org, OrganizationUpdateOptions(max_ttl_enabled=True))`. +- **Reducing TTL doesn't invalidate existing tokens.** Tokens issued + before a policy change keep their original expiration. Plan rotations + accordingly. +- **HCP Terraform vs TFE.** Both surfaces are available on both + platforms (this is not a TFE-only feature, unlike the SAML/SCIM/SMTP + admin endpoints). Documented version gates are not enforced + client-side; the server returns the authoritative error if a feature + isn't available. diff --git a/docs/scenarios/tfe-admin-bootstrap.md b/docs/scenarios/tfe-admin-bootstrap.md new file mode 100644 index 00000000..49eb6150 --- /dev/null +++ b/docs/scenarios/tfe-admin-bootstrap.md @@ -0,0 +1,176 @@ +# Scenario: TFE admin bootstrap (SMTP + org defaults + token TTL) + +This scenario covers the operational setup the first time a Terraform +Enterprise installation is brought into service — or the equivalent +HCP Terraform org-owner setup for the per-org pieces. Three orthogonal +pieces of state get touched: + +| Piece | Resource | TFE-only? | +|---|---|---| +| SMTP relay for verification emails / notifications | `client.admin.smtp_settings` | **yes** | +| Default execution mode + default agent pool for new workspaces | `client.organizations.{read,update,reset}_default_settings` | no | +| API-token max TTL per token type | `client.organization_token_ttl_policies` | no | + +The SMTP half requires a TFE **site-admin** token. The other two work +on either HCP Terraform or TFE with an org-owner token. + +Upstream references: + +- SAML/SCIM/SMTP admin docs: see [`tfe-identity-bootstrap.md`](tfe-identity-bootstrap.md) for the other admin pieces +- Org-defaults + token-TTL API reference: [`api/organization-defaults-and-token-ttl.md`](../api/organization-defaults-and-token-ttl.md) + +## Prerequisites + +```bash +# For SMTP (TFE-only): +export TFE_TOKEN="" +export TFE_ADDRESS="https://tfe.example.com" + +# For org defaults + token TTL (HCP or TFE): +export TFE_TOKEN="" +export TFE_ORG="my-org" +``` + +## Step 1: configure SMTP (TFE only) + +Email-based notifications, SCIM verification, and admin reset flows all +depend on a working SMTP relay. Set it up once at install time: + +```python +from pytfe import TFEClient +from pytfe.models import AdminSMTPSettingsUpdateOptions, SMTPAuthType + +client = TFEClient() + +client.admin.smtp_settings.update( + AdminSMTPSettingsUpdateOptions( + enabled=True, + host="smtp.example.com", + port=587, + sender="noreply@example.com", + auth=SMTPAuthType.LOGIN, + username="smtp-bot", + password="", + test_email_address="ops@example.com", + ) +) +``` + +Setting `test_email_address` triggers TFE to send a verification email +to that address as a side effect of the PATCH. The field is write-only +— subsequent reads won't surface it. + +The `password` field is sensitive. The transport-level debug logger +(`PYTFE_LOG=debug`) redacts it before output. Read [`docs/LOGGING.md`](../LOGGING.md) +for the full redaction list. + +## Step 2: choose a default execution mode for new workspaces + +If most workspaces in the org should use the same execution mode +(`remote`, `local`, or `agent`), set it as the org default so new +workspaces inherit it without explicit per-workspace configuration: + +```python +from pytfe.models import OrganizationDefaultSettingsUpdateOptions + +# Switch to agent execution as the default, pin a default pool +client.organizations.update_default_settings( + "my-org", + OrganizationDefaultSettingsUpdateOptions( + default_execution_mode="agent", + default_agent_pool_id="apool-abc123", + ), +) +``` + +Two important rules the SDK enforces locally (before any HTTP call): + +- `default_agent_pool_id` is only valid when `default_execution_mode="agent"`. + Setting a pool while asking for `remote` or `local` raises + `pydantic.ValidationError` at construction time. +- Omitting a field leaves the server value alone. Passing + `default_agent_pool_id=None` explicitly sends wire `null`, which + clears the pool. The two are distinguished by inspecting Pydantic's + `model_fields_set`. + +Read current defaults at any time: + +```python +defaults = client.organizations.read_default_settings("my-org") +print(defaults.default_execution_mode, defaults.default_agent_pool_id) +``` + +Reset to the safe baseline: + +```python +client.organizations.reset_default_settings("my-org") +# -> default_execution_mode="remote", default_agent_pool_id cleared +``` + +## Step 3: cap API-token lifetimes + +When you flip `max_ttl_enabled=True` on the organisation, TFE/HCP +enforces a per-token-type maximum lifetime. The default is 2 years for +all four token types; lower it to match your rotation policy. + +```python +from pytfe.models import ( + OrganizationUpdateOptions, + OrgTokenTTLPolicyUpdateOptions, +) + +# 1. Enable enforcement on the org +client.organizations.update( + "my-org", OrganizationUpdateOptions(max_ttl_enabled=True) +) + +# 2. Set per-token-type TTLs +client.organization_token_ttl_policies.update( + "my-org", + OrgTokenTTLPolicyUpdateOptions( + organization="1y", # org tokens: 1 year + team="90d", # team tokens: 90 days + user="30d", # user tokens: 30 days + audit_trails="2y", # audit-trails: 2 years + ), +) +``` + +The duration strings (`"1y"`, `"90d"`, `"30d"`, etc.) are parsed by the +SDK's `parse_ttl_to_ms()` helper. You can also pass integers (raw +milliseconds) if you prefer exact control. + +**Critical: the audit-trails token type uses an UNDERSCORE in the TTL +policy API.** The field is named `audit_trails`, not `audit-trails`. +This differs from the audit-trails token *creation* surface (which uses +the hyphen). The `TokenPolicyType.AUDIT_TRAILS` enum encodes the right +spelling so callers don't need to remember the distinction. + +Read current policies: + +```python +for policy in client.organization_token_ttl_policies.list("my-org"): + print(policy.token_type, policy.max_ttl_ms) +``` + +Reset all four token types back to the documented 2-year default: + +```python +client.organization_token_ttl_policies.reset_to_defaults("my-org") +``` + +## Operational notes + +- **Reducing TTL doesn't invalidate existing tokens.** Tokens issued + before a policy change keep their original `expired-at`. Plan a + rotation window when tightening limits. +- **Org-owner permission is sufficient** for steps 2 and 3. Only the + SMTP step (step 1) requires site-admin. +- **HCP vs TFE.** Steps 2 and 3 work the same on both platforms. Step 1 + is TFE-only — on HCP the request returns 404, surfaced as + `pytfe.errors.NotFound`. +- **Pair the empty-update guard with a confident dry run.** Building + `OrgTokenTTLPolicyUpdateOptions()` with no fields and calling + `update()` raises `RequiredFieldMissing` *before* any HTTP request. + Use that as a safety net in automation that constructs partial + updates conditionally. diff --git a/docs/scenarios/tfe-identity-bootstrap.md b/docs/scenarios/tfe-identity-bootstrap.md new file mode 100644 index 00000000..6288450e --- /dev/null +++ b/docs/scenarios/tfe-identity-bootstrap.md @@ -0,0 +1,206 @@ +# Scenario: TFE identity bootstrap (SAML + SCIM) + +Bringing up identity federation on a fresh Terraform Enterprise +installation involves three orthogonal pieces of state: + +1. **SAML** — how users authenticate. Configured on + `client.admin.saml_settings`. +2. **SCIM** — how user/group provisioning is automated. Configured on + `client.admin.scim_settings`. +3. **SCIM tokens** — the bearer tokens an IdP uses to make SCIM API + calls into TFE. Managed via `client.admin.scim_tokens`. + +This scenario walks the typical end-to-end bootstrap. All operations +require a TFE **site-admin** token. None of these endpoints are +available on HCP Terraform (SaaS) — they return `404` there. + +Upstream concept docs: + +- SAML SSO on TFE: https://developer.hashicorp.com/terraform/enterprise/saml +- SCIM on TFE: https://developer.hashicorp.com/terraform/enterprise/admin/scim + +API references in this repo: + +- [`api/admin-identity.md`](../api/admin-identity.md) + +## Prerequisites + +```bash +export TFE_TOKEN="" +export TFE_ADDRESS="https://tfe.example.com" +``` + +You also need: + +- The IdP's SSO/SLO endpoint URLs and signing certificate (for SAML). +- The IdP's SCIM ID of the group you want to grant TFE site-admin to + (for SCIM, optional). +- A clear rotation plan — both SAML certs and SCIM tokens are + long-lived credentials that need eventual replacement. + +## Step 1: Configure SAML + +```python +from pytfe import TFEClient +from pytfe.models import ( + AdminSAMLSettingsUpdateOptions, + SAMLProviderType, + SAMLSignatureMethod, +) + +client = TFEClient() + +# Plug in the IdP cert + endpoints. Enabling can be a separate step if +# you want to validate metadata first. +client.admin.saml_settings.update( + AdminSAMLSettingsUpdateOptions( + idp_cert="-----BEGIN CERTIFICATE-----\n...", + sso_endpoint_url="https://idp.example.com/sso", + slo_endpoint_url="https://idp.example.com/slo", + provider_type=SAMLProviderType.OKTA, + attr_username="Username", + attr_site_admin="SiteAdmin", + attr_groups="MemberOf", + site_admin_role="site-admins", + team_management_enabled=True, + authn_requests_signed=True, + signature_signing_method=SAMLSignatureMethod.SHA256, + signature_digest_method=SAMLSignatureMethod.SHA256, + ) +) + +# Once you've smoke-tested the IdP round-trip, enable it. +client.admin.saml_settings.update( + AdminSAMLSettingsUpdateOptions(enabled=True) +) +``` + +`provider_type` is a hint TFE uses to apply provider-specific quirks. +`SAMLProviderType.UNKNOWN` is the safe default; pick `OKTA`, `ENTRA`, +or `SAML` when you know the IdP. + +The ACS consumer and metadata URLs are computed by TFE; read them from +`client.admin.saml_settings.read()` and hand them to the IdP team. + +```python +saml = client.admin.saml_settings.read() +print("ACS consumer URL:", saml.acs_consumer_url) +print("SP metadata URL:", saml.metadata_url) +``` + +### Rotating the IdP certificate + +Two-step dance to avoid breaking in-flight SSO sessions: + +```python +# 1. Push the new cert. The old cert stays valid while users drain. +client.admin.saml_settings.update( + AdminSAMLSettingsUpdateOptions( + idp_cert="-----BEGIN CERTIFICATE-----\nNEW...", + ) +) + +# 2. After the rotation window — explicitly revoke the old cert. +client.admin.saml_settings.revoke_idp_cert() +``` + +## Step 2: Enable SCIM and bind a site-admin group + +```python +from pytfe.models import AdminSCIMSettingsUpdateOptions + +client.admin.scim_settings.update( + AdminSCIMSettingsUpdateOptions( + enabled=True, + paused=False, + site_admin_group_scim_id="", + ) +) +``` + +The `site_admin_group_scim_id` field has unusual wire semantics. Three +caller intents map to three wire payloads: + +- Don't pass the kwarg at all → the field is omitted from the request + → server keeps the current value untouched. +- `site_admin_group_scim_id="g-1"` → sent as a string → mapping is set + to that group. +- `site_admin_group_scim_id=None` (explicit) → sent as JSON `null` → + mapping is removed and SCIM-granted site-admin access is revoked. + +The SDK's `AdminSCIMSettingsUpdateOptions.to_payload()` handles this +distinction by inspecting Pydantic's `model_fields_set` instead of +relying on `exclude_none`. You don't need to do anything special — just +pass the value you mean. + +```python +# Pause provisioning without disabling +client.admin.scim_settings.update(AdminSCIMSettingsUpdateOptions(paused=True)) + +# Unlink the SCIM site-admin group (explicit null) +client.admin.scim_settings.update( + AdminSCIMSettingsUpdateOptions(site_admin_group_scim_id=None) +) + +# Fully disable SCIM (PATCH cannot do this — use delete) +client.admin.scim_settings.delete() +``` + +`delete()` disables provisioning. It does **not** revoke site-admin +access that SCIM previously granted to existing users — that has to be +revoked separately if needed. + +## Step 3: Mint a SCIM token for the IdP + +```python +from pytfe.models import AdminSCIMTokenCreateOptions + +token = client.admin.scim_tokens.create( + AdminSCIMTokenCreateOptions(description="okta-scim-bot-2026-Q2") +) +print("Token ID:", token.id) +print("Token value:", token.token) # capture now — never returned again +``` + +Operational rules: + +- The plaintext value of the token is **only** returned on this single + `create()` response. Every subsequent `list()` or `read()` returns + `None` for that field. Store the value in your secret manager + immediately. +- The SDK requires a non-empty `description`. Use something the SCIM + audit logs will be readable with — IdP name + rotation date is a + good pattern. +- Delete the previous token after the IdP is reconfigured to the new + one. Multiple SCIM tokens can coexist; that's how zero-downtime + rotation works. + +```python +# List existing tokens to find one to revoke +for tok in client.admin.scim_tokens.list(): + print(tok.id, tok.description, tok.created_at, tok.last_used_at) + +# Revoke +client.admin.scim_tokens.delete("at-...") +``` + +## Token rotation summary + +| Credential | Rotation method | +|---|---| +| SAML IdP cert | `update(idp_cert=...)` then `revoke_idp_cert()` after rotation window | +| SAML SP private key | `update(private_key=...)` — overwrites in place | +| SCIM token | `create()` new, switch IdP over, `delete()` old | + +## Operational notes + +- **TFE-only.** All endpoints in `client.admin.*` return `404` on HCP + Terraform. Don't write code that assumes both paths exist. +- **Site-admin scope.** All mutations require site-admin permission. A + workspace owner token will receive 403. +- **Read carefully before update.** SAML settings are a singleton — + `update()` is partial (only fields you set are sent), but a mistaken + `enabled=False` will lock everyone out of the SSO flow. +- **Treat SCIM tokens like SCIM passwords.** They grant the ability to + create and remove users; rotate them on the same cadence as any + long-lived API credential. diff --git a/examples/admin_identity.py b/examples/admin_identity.py new file mode 100644 index 00000000..486f20cf --- /dev/null +++ b/examples/admin_identity.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Reference example: TFE admin identity APIs (SAML / SCIM / SCIM tokens). + +All three resources are TFE-only — running this example against HCP +Terraform (SaaS) returns 404 on every call. The script never enables or +disables SAML/SCIM by default; it only reads the current state. To +exercise the write paths, set ``EXAMPLE_APPLY_WRITES=true``. + +Environment: + + TFE_TOKEN site-admin token + TFE_ADDRESS TFE base URL (e.g. https://tfe.example.com) + + Optional: + EXAMPLE_APPLY_WRITES "true" / "false" (default: "false") + Set to "true" to perform a no-op SAML update, a no-op SCIM + update, and to mint + immediately revoke a throwaway SCIM + token. Read-only operations always run. +""" + +from __future__ import annotations + +import os +import sys + +from pytfe import TFEClient +from pytfe.errors import NotFound, TFEError +from pytfe.models import ( + AdminSAMLSettingsUpdateOptions, + AdminSCIMSettingsUpdateOptions, + AdminSCIMTokenCreateOptions, +) + + +def banner(s: str) -> None: + print() + print("=" * 64) + print(s) + print("=" * 64) + + +def _read_saml(client: TFEClient) -> None: + banner("SAML settings") + saml = client.admin.saml_settings.read() + print(f" enabled: {saml.enabled}") + print(f" debug: {saml.debug}") + print(f" provider_type: {saml.provider_type}") + print(f" sso_endpoint_url: {saml.sso_endpoint_url}") + print(f" slo_endpoint_url: {saml.slo_endpoint_url}") + print(f" acs_consumer_url: {saml.acs_consumer_url}") + print(f" metadata_url: {saml.metadata_url}") + print(f" team_management_enabled: {saml.team_management_enabled}") + + +def _write_saml_noop(client: TFEClient) -> None: + # No-op write: refresh `debug` to its current value. Demonstrates the + # update path without changing observable state. + current = client.admin.saml_settings.read() + refreshed = client.admin.saml_settings.update( + AdminSAMLSettingsUpdateOptions(debug=current.debug or False) + ) + print(f" refreshed (debug={refreshed.debug})") + + +def _read_scim(client: TFEClient) -> None: + banner("SCIM settings") + scim = client.admin.scim_settings.read() + print(f" enabled: {scim.enabled}") + print(f" paused: {scim.paused}") + print(f" site_admin_group_scim_id: {scim.site_admin_group_scim_id}") + print(f" site_admin_group_display_name: {scim.site_admin_group_display_name}") + + +def _write_scim_noop(client: TFEClient) -> None: + # No-op write: refresh `paused` to its current value. Crucially we + # DON'T pass site_admin_group_scim_id at all — that's what tells the + # SDK to leave the server-side mapping alone (omit, not explicit null). + current = client.admin.scim_settings.read() + refreshed = client.admin.scim_settings.update( + AdminSCIMSettingsUpdateOptions(paused=bool(current.paused)) + ) + print(f" refreshed (paused={refreshed.paused})") + + +def _scim_token_round_trip(client: TFEClient) -> None: + banner("SCIM token (mint + revoke)") + # Mint a clearly-disposable token. + minted = client.admin.scim_tokens.create( + AdminSCIMTokenCreateOptions(description="pytfe-admin-example-disposable") + ) + print(f" minted: id={minted.id} description={minted.description!r}") + print(f" plaintext value (one-time): {minted.token!r}") + + # Confirm it shows up in list (without the plaintext value). + seen = [t for t in client.admin.scim_tokens.list() if t.id == minted.id] + if not seen: + print(" WARNING: minted token did not appear in list response") + else: + listed = seen[0] + print( + f" list confirms: id={listed.id} token={listed.token!r} (None expected)" + ) + + # Read by id. + read_back = client.admin.scim_tokens.read(minted.id) + print(f" read confirms: id={read_back.id} description={read_back.description!r}") + + # Revoke. + client.admin.scim_tokens.delete(minted.id) + print(f" revoked: {minted.id}") + + +def main() -> int: + client = TFEClient() + apply_writes = os.environ.get("EXAMPLE_APPLY_WRITES", "").lower() in ( + "1", + "true", + "yes", + ) + + try: + _read_saml(client) + if apply_writes: + print() + print("[EXAMPLE_APPLY_WRITES=true] performing SAML no-op refresh") + _write_saml_noop(client) + + _read_scim(client) + if apply_writes: + print() + print("[EXAMPLE_APPLY_WRITES=true] performing SCIM no-op refresh") + _write_scim_noop(client) + + banner("SCIM tokens") + for tok in client.admin.scim_tokens.list(): + print( + f" {tok.id} description={tok.description!r} " + f"created_at={tok.created_at} last_used_at={tok.last_used_at}" + ) + + if apply_writes: + print() + print("[EXAMPLE_APPLY_WRITES=true] minting + revoking a throwaway token") + _scim_token_round_trip(client) + + return 0 + except NotFound: + print() + print( + "Got 404 from a /admin/* endpoint. These resources are TFE-only " + "and are not available on HCP Terraform (SaaS) — check that " + "TFE_ADDRESS points at a Terraform Enterprise instance and " + "that TFE_TOKEN belongs to a site-admin user." + ) + return 1 + except TFEError as exc: + print(f"\nTFE error: {exc}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/admin_smtp.py b/examples/admin_smtp.py new file mode 100644 index 00000000..91850373 --- /dev/null +++ b/examples/admin_smtp.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Reference example: TFE admin SMTP settings. + +TFE-only. Running this against HCP Terraform (SaaS) returns 404 on every +call. The script defaults to a read-only operation; setting +``EXAMPLE_APPLY_WRITES=true`` will do a no-op refresh that re-PATCHes +the current values, and setting ``EXAMPLE_SEND_TEST_EMAIL=true`` plus +``EXAMPLE_TEST_EMAIL_ADDRESS=ops@example.com`` will trigger TFE to send +a verification email to that address as a side effect of the update. + +Environment: + + TFE_TOKEN site-admin token + TFE_ADDRESS TFE base URL + + Optional: + EXAMPLE_APPLY_WRITES "true" / "false" (default: "false") + EXAMPLE_SEND_TEST_EMAIL "true" / "false" (default: "false") + EXAMPLE_TEST_EMAIL_ADDRESS email address for test send +""" + +from __future__ import annotations + +import os +import sys + +from pytfe import TFEClient +from pytfe.errors import NotFound, TFEError +from pytfe.models import AdminSMTPSettingsUpdateOptions + + +def main() -> int: + client = TFEClient() + apply_writes = os.environ.get("EXAMPLE_APPLY_WRITES", "").lower() in ( + "1", + "true", + "yes", + ) + send_test = os.environ.get("EXAMPLE_SEND_TEST_EMAIL", "").lower() in ( + "1", + "true", + "yes", + ) + test_address = os.environ.get("EXAMPLE_TEST_EMAIL_ADDRESS") + + try: + print("=== SMTP settings ===") + smtp = client.admin.smtp_settings.read() + print(f" enabled: {smtp.enabled}") + print(f" host: {smtp.host}") + print(f" port: {smtp.port}") + print(f" sender: {smtp.sender}") + print(f" auth: {smtp.auth}") + print(f" username: {smtp.username}") + + if not apply_writes: + print( + "\nSet EXAMPLE_APPLY_WRITES=true to refresh the values " + "(no observable change)." + ) + return 0 + + # Refresh the host to its current value — demonstrates the + # update path with no observable effect. + print("\n[EXAMPLE_APPLY_WRITES=true] refreshing host to its current value") + options = AdminSMTPSettingsUpdateOptions(host=smtp.host) + if send_test and test_address: + print( + f"[EXAMPLE_SEND_TEST_EMAIL=true] also requesting test email to {test_address}" + ) + options = AdminSMTPSettingsUpdateOptions( + host=smtp.host, test_email_address=test_address + ) + refreshed = client.admin.smtp_settings.update(options) + print(f" refreshed host: {refreshed.host}") + return 0 + + except NotFound: + print( + "\nGot 404 from /api/v2/admin/smtp-settings. SMTP admin is " + "TFE-only and is not available on HCP Terraform (SaaS) — " + "check that TFE_ADDRESS points at a Terraform Enterprise " + "instance and that TFE_TOKEN belongs to a site-admin user." + ) + return 1 + except TFEError as exc: + print(f"\nTFE error: {exc}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/github_app_installations.py b/examples/github_app_installations.py new file mode 100644 index 00000000..c959644e --- /dev/null +++ b/examples/github_app_installations.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Reference example: list and read HCP Terraform GitHub App installations. + +The GitHub App authorisation flow itself happens through the HCP +Terraform UI (https://app.terraform.io -> settings -> GitHub Apps). +This SDK only exposes the lookup endpoints, which you use to discover +the ``github-app-installation-id`` value that workspace, stack, and +registry-module VCS configuration takes. + +Environment: + + TFE_TOKEN user token (read-only is fine) + TFE_ADDRESS HCP Terraform / Terraform Enterprise URL + + Optional: + GITHUB_APP_FILTER_NAME filter[name] passed to list + GITHUB_APP_FILTER_INSTALLATION_ID filter[installation_id] passed to list + GITHUB_APP_READ_ID if set, also do a single read on + this HCP-side installation ID + (e.g. ghain-...) +""" + +from __future__ import annotations + +import os +import sys + +from pytfe import TFEClient +from pytfe.errors import TFEError +from pytfe.models import GitHubAppInstallationListOptions + + +def main() -> int: + client = TFEClient() + + name_filter = os.environ.get("GITHUB_APP_FILTER_NAME") + install_id_filter = os.environ.get("GITHUB_APP_FILTER_INSTALLATION_ID") + read_id = os.environ.get("GITHUB_APP_READ_ID") + + list_options = None + if name_filter or install_id_filter: + list_options = GitHubAppInstallationListOptions( + name=name_filter, + installation_id=int(install_id_filter) if install_id_filter else None, + ) + + print("=== GitHub App installations visible to this token ===") + try: + installations = list(client.github_app_installations.list(list_options)) + except TFEError as exc: + print(f"\nlist failed: {exc}") + return 1 + + if not installations: + print( + " (none) — either this token can't see any installations, " + "or the supplied filters matched nothing." + ) + else: + for inst in installations: + print( + f" {inst.id} github_installation_id={inst.installation_id} " + f"type={inst.installation_type} name={inst.name!r} url={inst.installation_url}" + ) + + if read_id: + print() + print(f"=== Reading single installation: {read_id} ===") + try: + inst = client.github_app_installations.read(read_id) + except TFEError as exc: + print(f" read failed: {exc}") + return 1 + print(f" id: {inst.id}") + print(f" name: {inst.name}") + print(f" installation_id: {inst.installation_id} (GitHub-side numeric)") + print(f" installation_type: {inst.installation_type}") + print(f" installation_url: {inst.installation_url}") + print(f" icon_url: {inst.icon_url}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/org_token_ttl.py b/examples/org_token_ttl.py new file mode 100644 index 00000000..0694e386 --- /dev/null +++ b/examples/org_token_ttl.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Reference example: organisation API-token TTL policies. + +Reads the current per-token-type TTL policies for an org, and (when +opted in via ``EXAMPLE_APPLY_WRITES=true``) demonstrates two write +shapes: a partial update touching only one token type, and a +``reset_to_defaults()`` call. + +Environment: + + TFE_TOKEN org-owner token + TFE_ADDRESS HCP Terraform / Terraform Enterprise URL + TFE_ORG organisation name + + Optional: + EXAMPLE_APPLY_WRITES "true" / "false" (default: "false") + When true: PATCH the team-token TTL to 30 days, then + ``reset_to_defaults()``. Read-only operations always run. + +Re-runs are idempotent. +""" + +from __future__ import annotations + +import os +import sys + +from pytfe import TFEClient +from pytfe.errors import TFEError +from pytfe.models import ( + DEFAULT_MAX_TTL_MS, + OrgTokenTTLPolicyUpdateOptions, +) + + +def _print_policies(client: TFEClient, organization: str) -> None: + policies = list(client.organization_token_ttl_policies.list(organization)) + if not policies: + print(" (none — server reports no per-token-type policies set)") + return + for p in policies: + days = (p.max_ttl_ms or 0) // 86_400_000 + print(f" {p.token_type!s:>40} max_ttl_ms={p.max_ttl_ms:>14,} (~{days} days)") + + +def main() -> int: + organization = os.environ["TFE_ORG"] + apply_writes = os.environ.get("EXAMPLE_APPLY_WRITES", "").lower() in ( + "1", + "true", + "yes", + ) + + client = TFEClient() + + try: + print(f"=== Current policies for {organization} ===") + _print_policies(client, organization) + + if not apply_writes: + print( + "\nSet EXAMPLE_APPLY_WRITES=true to demonstrate a partial " + "update + reset_to_defaults()." + ) + return 0 + + print("\n[EXAMPLE_APPLY_WRITES=true] tightening team token TTL to 30 days") + client.organization_token_ttl_policies.update( + organization, + OrgTokenTTLPolicyUpdateOptions(team="30d"), + ) + + print("\nPost-update policies:") + _print_policies(client, organization) + + print("\n[EXAMPLE_APPLY_WRITES=true] reset_to_defaults() — all 4 types -> 2y") + client.organization_token_ttl_policies.reset_to_defaults(organization) + + print("\nFinal policies (should all be the 2-year default):") + _print_policies(client, organization) + print(f"\n(default = {DEFAULT_MAX_TTL_MS:,} ms = 2 years)") + return 0 + + except TFEError as exc: + print(f"\nTFE error: {exc}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/pytfe/_logging.py b/src/pytfe/_logging.py index 98297101..950d3b3e 100644 --- a/src/pytfe/_logging.py +++ b/src/pytfe/_logging.py @@ -77,6 +77,12 @@ _SENSITIVE_HEADER_SUBSTRINGS = ("token", "secret", "password", "api-key", "apikey") # JSON keys whose values are redacted recursively in body dumps. +# Both snake_case and hyphenated forms are listed because the JSON:API +# wire format hyphenates ("private-key") and the comparison below is a +# case-insensitive set membership test, not a normalising one. +# X.509 certificate fields ("idp-cert", "certificate") are deliberately +# NOT redacted — those are public material by design and redacting them +# hurts debugging without protecting anything. _SENSITIVE_JSON_KEYS = frozenset( { "token", @@ -85,6 +91,7 @@ "secret", "password", "private_key", + "private-key", "client_secret", } ) diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 63548716..01e48645 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -5,16 +5,19 @@ from ._http import HTTPTransport from .config import TFEConfig +from .resources.admin import AdminClient from .resources.agent_pools import AgentPools from .resources.agents import Agents, AgentTokens from .resources.apply import Applies from .resources.comment import Comments from .resources.configuration_version import ConfigurationVersions from .resources.explorer import Explorer +from .resources.github_app_installation import GitHubAppInstallations from .resources.no_code_module import NoCodeModules from .resources.notification_configuration import NotificationConfigurations from .resources.oauth_client import OAuthClients from .resources.oauth_token import OAuthTokens +from .resources.org_token_ttl_policy import OrganizationTokenTTLPolicies from .resources.organization_audit_configuration import OrganizationAuditConfigurations from .resources.organization_membership import OrganizationMemberships from .resources.organization_tags import OrganizationTags @@ -83,6 +86,18 @@ def __init__(self, config: TFEConfig | None = None): self.agents = Agents(self._transport) self.agent_tokens = AgentTokens(self._transport) + # TFE admin namespace (SAML / SCIM / SCIM tokens) + self.admin = AdminClient(self._transport) + + # GitHub App installation discovery + self.github_app_installations = GitHubAppInstallations(self._transport) + + # Org-wide API-token TTL policy (pairs with max_ttl_enabled on + # the parent organisation) + self.organization_token_ttl_policies = OrganizationTokenTTLPolicies( + self._transport + ) + # Core resources self.configuration_versions = ConfigurationVersions(self._transport) self.notification_configurations = NotificationConfigurations(self._transport) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index dc870dae..032a0c12 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -739,3 +739,34 @@ class RequiredRegistryModuleIDError(RequiredFieldMissing): def __init__(self, message: str = "registry module ID is required"): super().__init__(message) + + +# Admin SAML/SCIM + GitHub App installation errors +class InvalidSAMLProviderTypeError(InvalidValues): + """Raised when an unrecognised SAML provider type is supplied.""" + + def __init__(self, message: str = "invalid value for SAML provider type") -> None: + super().__init__(message) + + +class InvalidSCIMTokenIDError(InvalidValues): + """Raised when an invalid SCIM token ID is supplied.""" + + def __init__(self, message: str = "invalid value for SCIM token ID") -> None: + super().__init__(message) + + +class RequiredSCIMTokenDescriptionError(RequiredFieldMissing): + """Raised when a SCIM token is created without a non-empty description.""" + + def __init__(self, message: str = "SCIM token description is required") -> None: + super().__init__(message) + + +class InvalidGitHubAppInstallationIDError(InvalidValues): + """Raised when an invalid GitHub App installation ID is supplied.""" + + def __init__( + self, message: str = "invalid value for GitHub App installation ID" + ) -> None: + super().__init__(message) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index 6ca910dd..341ebcc5 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -3,6 +3,60 @@ from __future__ import annotations +# ── TFE admin identity (SAML / SCIM) ────────────────────────────────────────── +from .admin_identity import ( + AdminSAMLSettings, + AdminSAMLSettingsUpdateOptions, + AdminSCIMSettings, + AdminSCIMSettingsUpdateOptions, + AdminSCIMToken, + AdminSCIMTokenCreateOptions, + AdminSMTPSettings, + AdminSMTPSettingsUpdateOptions, + SAMLProviderType, + SAMLSignatureMethod, + SMTPAuthType, +) + +# ── TFE admin organizations ─────────────────────────────────────────────────── +from .admin_organization import ( + AdminOrganization, + AdminOrganizationListOptions, + AdminOrganizationUpdateOptions, +) + +# ── TFE admin runs ──────────────────────────────────────────────────────────── +from .admin_run import ( + AdminRun, + AdminRunListOptions, +) + +# ── TFE admin users ─────────────────────────────────────────────────────────── +from .admin_user import ( + AdminUser, + AdminUserListOptions, +) + +# ── TFE admin versions (Terraform / OPA / Sentinel) ────────────────────────── +from .admin_version import ( + OpaVersion, + OpaVersionCreateOptions, + OpaVersionUpdateOptions, + SentinelVersion, + SentinelVersionCreateOptions, + SentinelVersionUpdateOptions, + TerraformVersion, + TerraformVersionCreateOptions, + TerraformVersionUpdateOptions, + ToolVersionArchitecture, +) + +# ── TFE admin workspaces ────────────────────────────────────────────────────── +from .admin_workspace import ( + AdminWorkspace, + AdminWorkspaceListOptions, +) + # ── Agent & Agent Pools ──────────────────────────────────────────────────────── from .agent import ( Agent, @@ -75,6 +129,13 @@ ExplorerViewType, ) +# ── GitHub App Installations ───────────────────────────────────────────────── +from .github_app_installation import ( + GitHubAppInstallation, + GitHubAppInstallationListOptions, + GitHubAppInstallationType, +) + # ── Notification Configurations ─────────────────────────────────────────────── from .no_code_module import ( NoCodeModule, @@ -119,6 +180,13 @@ OAuthTokenListOptions, OAuthTokenUpdateOptions, ) +from .org_token_ttl_policy import ( + DEFAULT_MAX_TTL_MS, + OrgTokenTTLPolicy, + OrgTokenTTLPolicyUpdateOptions, + TokenPolicyType, + parse_ttl_to_ms, +) # Organization / Project from .organization import ( @@ -126,6 +194,8 @@ ExecutionMode, Organization, OrganizationCreateOptions, + OrganizationDefaultSettings, + OrganizationDefaultSettingsUpdateOptions, OrganizationUpdateOptions, ReadRunQueueOptions, RunQueue, @@ -536,6 +606,46 @@ # ── Public surface ──────────────────────────────────────────────────────────── __all__ = [ + # TFE admin versions + "ToolVersionArchitecture", + "TerraformVersion", + "TerraformVersionCreateOptions", + "TerraformVersionUpdateOptions", + "OpaVersion", + "OpaVersionCreateOptions", + "OpaVersionUpdateOptions", + "SentinelVersion", + "SentinelVersionCreateOptions", + "SentinelVersionUpdateOptions", + # TFE admin runs + "AdminRun", + "AdminRunListOptions", + # TFE admin organizations + "AdminOrganization", + "AdminOrganizationListOptions", + "AdminOrganizationUpdateOptions", + # TFE admin users + "AdminUser", + "AdminUserListOptions", + # TFE admin workspaces + "AdminWorkspace", + "AdminWorkspaceListOptions", + # TFE admin identity + "AdminSAMLSettings", + "AdminSAMLSettingsUpdateOptions", + "AdminSCIMSettings", + "AdminSCIMSettingsUpdateOptions", + "AdminSCIMToken", + "AdminSCIMTokenCreateOptions", + "AdminSMTPSettings", + "AdminSMTPSettingsUpdateOptions", + "SAMLProviderType", + "SAMLSignatureMethod", + "SMTPAuthType", + # GitHub App installations + "GitHubAppInstallation", + "GitHubAppInstallationListOptions", + "GitHubAppInstallationType", # No-code provisioning "NoCodeModule", "NoCodeModuleCreateOptions", @@ -697,7 +807,15 @@ "Pagination", "Organization", "OrganizationCreateOptions", + "OrganizationDefaultSettings", + "OrganizationDefaultSettingsUpdateOptions", "OrganizationUpdateOptions", + # Org-token TTL policy + "DEFAULT_MAX_TTL_MS", + "OrgTokenTTLPolicy", + "OrgTokenTTLPolicyUpdateOptions", + "TokenPolicyType", + "parse_ttl_to_ms", "OrganizationAuditConfigAuditStreaming", "OrganizationAuditConfigAuditTrails", "OrganizationAuditConfigPermissions", diff --git a/src/pytfe/models/admin_identity.py b/src/pytfe/models/admin_identity.py new file mode 100644 index 00000000..ec110b3a --- /dev/null +++ b/src/pytfe/models/admin_identity.py @@ -0,0 +1,307 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Models for Terraform Enterprise admin identity APIs. + +This module covers three closely related TFE-only admin resources: + +- SAML settings (``/api/v2/admin/saml-settings``) +- SCIM settings (``/api/v2/admin/scim-settings``) +- SCIM tokens (``/api/v2/admin/scim-tokens``) + +These endpoints are TFE-only — they are not available on HCP Terraform +(SaaS). The SDK does not enforce that; the server returns 404 on HCP and +the SDK surfaces it as ``pytfe.errors.NotFound``. + +Per the upstream API, several SAML attributes are write-only or sensitive +(``private-key`` in particular). The transport-level logger redacts these +keys before they ever reach the log; see ``pytfe._logging``. +""" + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +# --------------------------------------------------------------------------- +# SAML +# --------------------------------------------------------------------------- + + +class SAMLProviderType(str, Enum): + """Provider hint sent to TFE so it can apply provider-specific + SAML quirks. ``UNKNOWN`` is the safe default for generic IdPs.""" + + OKTA = "okta" + ENTRA = "entra" + SAML = "saml" + UNKNOWN = "unknown" + + +class SAMLSignatureMethod(str, Enum): + """Digest / signing algorithm for SP-signed SAML requests.""" + + SHA1 = "SHA1" + SHA256 = "SHA256" + + +class AdminSAMLSettings(BaseModel): + """Snapshot of the organisation's SAML settings on TFE.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str | None = None + + # Lifecycle / debug flags. + enabled: bool | None = None + debug: bool | None = None + + # Identity provider configuration. + idp_cert: str | None = Field(default=None, alias="idp-cert") + old_idp_cert: str | None = Field(default=None, alias="old-idp-cert") + slo_endpoint_url: str | None = Field(default=None, alias="slo-endpoint-url") + sso_endpoint_url: str | None = Field(default=None, alias="sso-endpoint-url") + + # Service-provider configuration (read-back on TFE; written via update). + acs_consumer_url: str | None = Field(default=None, alias="acs-consumer-url") + metadata_url: str | None = Field(default=None, alias="metadata-url") + certificate: str | None = None + # ``private-key`` is sensitive; the API never returns it on read, but + # we model it here so users can spot it on the type if they go looking. + private_key: str | None = Field(default=None, alias="private-key") + + # Attribute mapping. + attr_username: str | None = Field(default=None, alias="attr-username") + attr_groups: str | None = Field(default=None, alias="attr-groups") + attr_site_admin: str | None = Field(default=None, alias="attr-site-admin") + site_admin_role: str | None = Field(default=None, alias="site-admin-role") + + # SP-signed request behaviour. + authn_requests_signed: bool | None = Field( + default=None, alias="authn-requests-signed" + ) + want_assertions_signed: bool | None = Field( + default=None, alias="want-assertions-signed" + ) + signature_signing_method: SAMLSignatureMethod | None = Field( + default=None, alias="signature-signing-method" + ) + signature_digest_method: SAMLSignatureMethod | None = Field( + default=None, alias="signature-digest-method" + ) + + # Team mapping + session lifetime. + team_management_enabled: bool | None = Field( + default=None, alias="team-management-enabled" + ) + sso_api_token_session_timeout: int | None = Field( + default=None, alias="sso-api-token-session-timeout" + ) + + # Provider hint. + provider_type: SAMLProviderType | None = Field(default=None, alias="provider-type") + + +class AdminSAMLSettingsUpdateOptions(BaseModel): + """Partial update options for SAML settings. + + Every field is optional; only fields the caller sets are emitted on + the wire, and the server preserves the rest. Pass + ``provider_type=SAMLProviderType.OKTA`` to switch the provider hint; + pass ``private_key="..."`` to install a new SP signing key (sensitive, + not returned on read). + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + enabled: bool | None = None + debug: bool | None = None + + idp_cert: str | None = Field(default=None, alias="idp-cert") + slo_endpoint_url: str | None = Field(default=None, alias="slo-endpoint-url") + sso_endpoint_url: str | None = Field(default=None, alias="sso-endpoint-url") + + certificate: str | None = None + private_key: str | None = Field(default=None, alias="private-key") + + attr_username: str | None = Field(default=None, alias="attr-username") + attr_groups: str | None = Field(default=None, alias="attr-groups") + attr_site_admin: str | None = Field(default=None, alias="attr-site-admin") + site_admin_role: str | None = Field(default=None, alias="site-admin-role") + + authn_requests_signed: bool | None = Field( + default=None, alias="authn-requests-signed" + ) + want_assertions_signed: bool | None = Field( + default=None, alias="want-assertions-signed" + ) + signature_signing_method: SAMLSignatureMethod | None = Field( + default=None, alias="signature-signing-method" + ) + signature_digest_method: SAMLSignatureMethod | None = Field( + default=None, alias="signature-digest-method" + ) + + team_management_enabled: bool | None = Field( + default=None, alias="team-management-enabled" + ) + sso_api_token_session_timeout: int | None = Field( + default=None, alias="sso-api-token-session-timeout" + ) + + provider_type: SAMLProviderType | None = Field(default=None, alias="provider-type") + + +# --------------------------------------------------------------------------- +# SCIM settings +# --------------------------------------------------------------------------- + + +class AdminSCIMSettings(BaseModel): + """Snapshot of the organisation's SCIM settings on TFE.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str | None = None + enabled: bool | None = None + paused: bool | None = None + site_admin_group_scim_id: str | None = Field( + default=None, alias="site-admin-group-scim-id" + ) + site_admin_group_display_name: str | None = Field( + default=None, alias="site-admin-group-display-name" + ) + + +class AdminSCIMSettingsUpdateOptions(BaseModel): + """Partial update options for SCIM settings. + + ``site_admin_group_scim_id`` has special wire semantics: + + - Omit it (don't pass the kwarg) and the server keeps the current + value. + - Pass ``site_admin_group_scim_id=None`` explicitly and the wire + payload contains ``"site-admin-group-scim-id": null``, which + revokes the SCIM site-admin mapping. + + Pydantic's ``exclude_none=True`` cannot distinguish the two cases, so + the resource layer calls :meth:`to_payload` which uses + ``model_fields_set`` to tell them apart. + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + enabled: bool | None = None + paused: bool | None = None + site_admin_group_scim_id: str | None = Field( + default=None, alias="site-admin-group-scim-id" + ) + + def to_payload(self) -> dict[str, Any]: + """Build the JSON:API ``attributes`` dict honouring the + omit-vs-explicit-null distinction documented above. + """ + attrs: dict[str, Any] = {} + set_fields = self.model_fields_set + if "enabled" in set_fields and self.enabled is not None: + attrs["enabled"] = self.enabled + if "paused" in set_fields and self.paused is not None: + attrs["paused"] = self.paused + # site_admin_group_scim_id: None means "send JSON null". Only the + # explicit-unset path drops the key entirely. + if "site_admin_group_scim_id" in set_fields: + attrs["site-admin-group-scim-id"] = self.site_admin_group_scim_id + return attrs + + +# --------------------------------------------------------------------------- +# SCIM tokens +# --------------------------------------------------------------------------- + + +class AdminSCIMToken(BaseModel): + """A SCIM provisioning token. ``token`` (the plaintext bearer value) + is only populated on the response to :meth:`create`; ``list`` and + :meth:`read` always return ``None`` for that field. + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str | None = None + description: str | None = None + token: str | None = None + expired_at: datetime | None = Field(default=None, alias="expired-at") + created_at: datetime | None = Field(default=None, alias="created-at") + last_used_at: datetime | None = Field(default=None, alias="last-used-at") + + +class AdminSCIMTokenCreateOptions(BaseModel): + """Options for minting a new SCIM token. + + ``description`` is required by this SDK (presence + non-empty); the + upstream API marks it optional but a missing or empty description + leaves the token un-identifiable in audit, so the SDK enforces it. + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + description: str = Field( + ..., description="Human-readable description shown in audit logs." + ) + expired_at: datetime | None = Field(default=None, alias="expired-at") + + +# --------------------------------------------------------------------------- +# SMTP settings +# --------------------------------------------------------------------------- + + +class SMTPAuthType(str, Enum): + """Authentication mechanism for the SMTP relay.""" + + NONE = "none" + PLAIN = "plain" + LOGIN = "login" + + +class AdminSMTPSettings(BaseModel): + """Snapshot of the organisation's SMTP relay settings on TFE. + + ``password`` and ``test_email_address`` are write-only on the upstream + API and are never returned by ``read()`` — modelled here as fields + only on the update options below. + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str | None = None + enabled: bool | None = None + host: str | None = None + port: int | None = None + sender: str | None = None + auth: SMTPAuthType | None = None + username: str | None = None + + +class AdminSMTPSettingsUpdateOptions(BaseModel): + """Partial update options for SMTP settings. + + Every field is optional. ``password`` is sensitive; the transport + logger redacts it before debug output. ``test_email_address`` is a + write-only signal — when set, TFE sends a verification email to that + address as part of the PATCH; the field is not returned on read. + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + enabled: bool | None = None + host: str | None = None + port: int | None = None + sender: str | None = None + auth: SMTPAuthType | None = None + username: str | None = None + password: str | None = None + test_email_address: str | None = Field(default=None, alias="test-email-address") diff --git a/src/pytfe/models/admin_organization.py b/src/pytfe/models/admin_organization.py new file mode 100644 index 00000000..96328c4a --- /dev/null +++ b/src/pytfe/models/admin_organization.py @@ -0,0 +1,53 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class AdminOrganizationListOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + query: str | None = Field(default=None, alias="q") + page_number: int | None = Field(default=None, alias="page[number]") + page_size: int | None = Field(default=None, alias="page[size]") + + +class AdminOrganization(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + id: str | None = None + name: str | None = None + email: str | None = None + plan_expired: bool | None = Field(default=None, alias="plan-expired") + plan_expires_at: str | None = Field(default=None, alias="plan-expires-at") + plan_is_enterprise: bool | None = Field(default=None, alias="plan-is-enterprise") + plan_is_trial: bool | None = Field(default=None, alias="plan-is-trial") + plan_identifier: str | None = Field(default=None, alias="plan-identifier") + fair_run_queuing_enabled: bool | None = Field( + default=None, alias="fair-run-queuing-enabled" + ) + owners_team_saml_role_id: str | None = Field( + default=None, alias="owners-team-saml-role-id" + ) + two_factor_conformant: bool | None = Field( + default=None, alias="two-factor-conformant" + ) + global_module_sharing: bool | None = Field( + default=None, alias="global-module-sharing" + ) + global_provider_sharing: bool | None = Field( + default=None, alias="global-provider-sharing" + ) + + +class AdminOrganizationUpdateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + global_module_sharing: bool | None = Field( + default=None, alias="global-module-sharing" + ) + global_provider_sharing: bool | None = Field( + default=None, alias="global-provider-sharing" + ) + owners_team_saml_role_id: str | None = Field( + default=None, alias="owners-team-saml-role-id" + ) diff --git a/src/pytfe/models/admin_run.py b/src/pytfe/models/admin_run.py new file mode 100644 index 00000000..8bd25091 --- /dev/null +++ b/src/pytfe/models/admin_run.py @@ -0,0 +1,28 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from .run import RunStatus + + +class AdminRunListOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + run_status: str | None = Field(default=None, alias="filter[status]") + query: str | None = Field(default=None, alias="q") + page_number: int | None = Field(default=None, alias="page[number]") + page_size: int | None = Field(default=None, alias="page[size]") + + +class AdminRun(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + id: str | None = None + status: RunStatus | None = None + has_changes: bool | None = Field(default=None, alias="has-changes") + plan_only: bool | None = Field(default=None, alias="plan-only") + # Populated from the `workspace` relationship (always present). + workspace_id: str | None = None + # Populated only when `?include=workspace.organization` is passed. + organization_name: str | None = None diff --git a/src/pytfe/models/admin_user.py b/src/pytfe/models/admin_user.py new file mode 100644 index 00000000..0a78a01a --- /dev/null +++ b/src/pytfe/models/admin_user.py @@ -0,0 +1,27 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class AdminUserListOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + query: str | None = Field(default=None, alias="q") + administrators: bool | None = None + suspended: bool | None = None + page_number: int | None = Field(default=None, alias="page[number]") + page_size: int | None = Field(default=None, alias="page[size]") + + +class AdminUser(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + id: str | None = None + username: str | None = None + email: str | None = None + avatar_url: str | None = Field(default=None, alias="avatar-url") + is_admin: bool | None = Field(default=None, alias="is-admin") + is_suspended: bool | None = Field(default=None, alias="is-suspended") + two_factor_enabled: bool | None = Field(default=None, alias="two-factor-enabled") + two_factor_verified: bool | None = Field(default=None, alias="two-factor-verified") diff --git a/src/pytfe/models/admin_version.py b/src/pytfe/models/admin_version.py new file mode 100644 index 00000000..2634ab03 --- /dev/null +++ b/src/pytfe/models/admin_version.py @@ -0,0 +1,140 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class ToolVersionArchitecture(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + url: str | None = None + sha: str | None = None + os: str | None = None + arch: str | None = None + + +class TerraformVersion(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + id: str | None = None + version: str | None = None + url: str | None = None + sha: str | None = None + official: bool | None = None + enabled: bool | None = None + beta: bool | None = None + deprecated: bool | None = None + deprecated_reason: str | None = Field(default=None, alias="deprecated-reason") + usage: int | None = None + created_at: str | None = Field(default=None, alias="created-at") + archs: list[ToolVersionArchitecture] | None = None + + +class TerraformVersionCreateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + version: str + url: str + sha: str + official: bool | None = None + enabled: bool | None = None + beta: bool | None = None + deprecated: bool | None = None + deprecated_reason: str | None = Field(default=None, alias="deprecated-reason") + archs: list[ToolVersionArchitecture] | None = None + + +class TerraformVersionUpdateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + version: str | None = None + url: str | None = None + sha: str | None = None + official: bool | None = None + enabled: bool | None = None + beta: bool | None = None + deprecated: bool | None = None + deprecated_reason: str | None = Field(default=None, alias="deprecated-reason") + archs: list[ToolVersionArchitecture] | None = None + + +class OpaVersion(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + id: str | None = None + version: str | None = None + url: str | None = None + sha: str | None = None + official: bool | None = None + enabled: bool | None = None + beta: bool | None = None + deprecated: bool | None = None + deprecated_reason: str | None = Field(default=None, alias="deprecated-reason") + usage: int | None = None + created_at: str | None = Field(default=None, alias="created-at") + archs: list[ToolVersionArchitecture] | None = None + + +class OpaVersionCreateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + version: str + url: str + sha: str + official: bool | None = None + enabled: bool | None = None + beta: bool | None = None + deprecated: bool | None = None + deprecated_reason: str | None = Field(default=None, alias="deprecated-reason") + archs: list[ToolVersionArchitecture] | None = None + + +class OpaVersionUpdateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + version: str | None = None + url: str | None = None + sha: str | None = None + official: bool | None = None + enabled: bool | None = None + beta: bool | None = None + deprecated: bool | None = None + deprecated_reason: str | None = Field(default=None, alias="deprecated-reason") + archs: list[ToolVersionArchitecture] | None = None + + +class SentinelVersion(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + id: str | None = None + version: str | None = None + url: str | None = None + sha: str | None = None + official: bool | None = None + enabled: bool | None = None + beta: bool | None = None + deprecated: bool | None = None + deprecated_reason: str | None = Field(default=None, alias="deprecated-reason") + usage: int | None = None + created_at: str | None = Field(default=None, alias="created-at") + archs: list[ToolVersionArchitecture] | None = None + + +class SentinelVersionCreateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + version: str + url: str + sha: str + official: bool | None = None + enabled: bool | None = None + beta: bool | None = None + deprecated: bool | None = None + deprecated_reason: str | None = Field(default=None, alias="deprecated-reason") + archs: list[ToolVersionArchitecture] | None = None + + +class SentinelVersionUpdateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + version: str | None = None + url: str | None = None + sha: str | None = None + official: bool | None = None + enabled: bool | None = None + beta: bool | None = None + deprecated: bool | None = None + deprecated_reason: str | None = Field(default=None, alias="deprecated-reason") + archs: list[ToolVersionArchitecture] | None = None diff --git a/src/pytfe/models/admin_workspace.py b/src/pytfe/models/admin_workspace.py new file mode 100644 index 00000000..7acb2ad8 --- /dev/null +++ b/src/pytfe/models/admin_workspace.py @@ -0,0 +1,26 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class AdminWorkspaceListOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + query: str | None = Field(default=None, alias="q") + page_number: int | None = Field(default=None, alias="page[number]") + page_size: int | None = Field(default=None, alias="page[size]") + + +class AdminWorkspace(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + id: str | None = None + name: str | None = None + locked: bool | None = None + # vcs-repo is an attribute object; only the identifier is surfaced here. + vcs_repo_identifier: str | None = None + # Lifted from the `organization` relationship at parse time. + organization_name: str | None = None + # Lifted from the `current-run` relationship at parse time. + current_run_id: str | None = None diff --git a/src/pytfe/models/github_app_installation.py b/src/pytfe/models/github_app_installation.py new file mode 100644 index 00000000..ca8faf44 --- /dev/null +++ b/src/pytfe/models/github_app_installation.py @@ -0,0 +1,62 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Models for HCP Terraform's GitHub App installation discovery API. + +These resources are read-only. The actual GitHub App authorisation flow +happens through the HCP Terraform UI; this SDK only exposes the lookup +needed when callers want to find the ``github-app-installation-id`` to +plug into workspace, stack, or registry-module VCS configuration. +""" + +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + + +class GitHubAppInstallationType(str, Enum): + """Whether the GitHub App is installed against a user account or + an organization. The upstream API returns these as wire strings + capitalized (verified live: ``"Organization"`` / ``"User"``); the + enum mirrors that exactly so equality checks work without case + coercion. The model field itself is typed ``str | None`` because + the API has historically been case-inconsistent across versions + and we don't want construction to fail on a value we haven't seen + before.""" + + USER = "User" + ORGANIZATION = "Organization" + + +class GitHubAppInstallation(BaseModel): + """A GitHub App installation visible to the authenticated user.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str | None = None + name: str | None = None + # `installation-id` is the *GitHub-side* numeric installation ID, not + # the HCP Terraform internal `id`. Modelled as int for type fidelity + # against the API which returns it unquoted. + installation_id: int | None = Field(default=None, alias="installation-id") + icon_url: str | None = Field(default=None, alias="icon-url") + installation_type: str | None = Field(default=None, alias="installation-type") + installation_url: str | None = Field(default=None, alias="installation-url") + + +class GitHubAppInstallationListOptions(BaseModel): + """List filters for GitHub App installations. + + The upstream API documents two filter parameters and does not + document pagination on this endpoint. + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + # Filter by the GitHub login/organization name (matches `name`). + name: str | None = Field(default=None, alias="filter[name]") + # Filter by the GitHub-side numeric installation ID (matches + # `installation-id`), not HCP Terraform's internal `id`. + installation_id: int | None = Field(default=None, alias="filter[installation_id]") diff --git a/src/pytfe/models/org_token_ttl_policy.py b/src/pytfe/models/org_token_ttl_policy.py new file mode 100644 index 00000000..24adf20d --- /dev/null +++ b/src/pytfe/models/org_token_ttl_policy.py @@ -0,0 +1,185 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Models for the organisation API-token TTL policy resource. + +Exposes the per-token-type "max TTL" knobs that pair with the +``max_ttl_enabled`` toggle on the parent ``Organization``. The upstream +endpoints are: + +- ``GET /api/v2/organizations/{org}/token-ttl-policies`` +- ``PATCH /api/v2/organizations/{org}/token-ttl-policies`` + +with JSON:API type ``organization-token-ttl-policies``. + +The list payload returns one item per token type. The update payload +sends one item per token type the caller wants to change. + +**Token-type spelling note.** This API uses +``token-type=audit_trails`` (UNDERSCORE) for the audit-trail policy +entry. That's deliberately different from the audit-trail token +*creation* surface elsewhere in the API which uses +``audit-trails`` (HYPHEN). The :class:`TokenPolicyType` enum below +mirrors the TTL-specific spelling exactly so the two surfaces don't get +accidentally cross-wired. +""" + +from __future__ import annotations + +import re +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +# 2 years in milliseconds — the documented default the upstream applies +# when no per-token policy is set. Exported for callers who want to +# reset to defaults without recomputing. +DEFAULT_MAX_TTL_MS: int = 63_072_000_000 + + +class TokenPolicyType(str, Enum): + """Token types accepted by the org TTL policy endpoint. + + See the module docstring for the audit-trails spelling rationale. + """ + + ORGANIZATION = "organization" + TEAM = "team" + USER = "user" + AUDIT_TRAILS = "audit_trails" + + +class OrgTokenTTLPolicy(BaseModel): + """One token-type / max-TTL entry as returned by the list endpoint.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str | None = None + token_type: TokenPolicyType | None = Field(default=None, alias="token-type") + max_ttl_ms: int | None = Field(default=None, alias="max-ttl-ms") + + +class OrgTokenTTLPolicyUpdateOptions(BaseModel): + """Update options addressed by token type. + + Each of the four fields accepts either: + + - An ``int`` (raw milliseconds, e.g. ``63_072_000_000`` for 2 years). + - A duration string (``"1h"``, ``"30d"``, ``"6mo"``, ``"2y"``). Parsed + by :func:`parse_ttl_to_ms` at payload-build time. + - ``None`` (omit this entry — the server keeps the existing policy + for that token type). + + At least one field must be supplied; ``to_payload()`` raises + :class:`pytfe.errors.RequiredFieldMissing` if you build a no-op + update. + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + organization: int | str | None = None + team: int | str | None = None + user: int | str | None = None + audit_trails: int | str | None = None + + def to_payload(self) -> list[dict[str, Any]]: + """Serialize to the JSON:API ``data`` array. Each non-``None`` + field becomes one ``{type, attributes: {token-type, max-ttl-ms}}`` + entry. Empty result is an error — see class docstring. + """ + # Local import to avoid a cycle: errors imports models indirectly + # through other modules. + from ..errors import RequiredFieldMissing + + items: list[dict[str, Any]] = [] + for field_name, token_type in [ + ("organization", TokenPolicyType.ORGANIZATION), + ("team", TokenPolicyType.TEAM), + ("user", TokenPolicyType.USER), + ("audit_trails", TokenPolicyType.AUDIT_TRAILS), + ]: + raw = getattr(self, field_name) + if raw is None: + continue + ms = parse_ttl_to_ms(raw) if isinstance(raw, str) else int(raw) + items.append( + { + "type": "organization-token-ttl-policies", + "attributes": { + "token-type": token_type.value, + "max-ttl-ms": ms, + }, + } + ) + if not items: + raise RequiredFieldMissing( + "OrgTokenTTLPolicyUpdateOptions requires at least one of " + "organization, team, user, or audit_trails to be set." + ) + return items + + +# --------------------------------------------------------------------------- +# Duration parser +# --------------------------------------------------------------------------- + +# Suffix multipliers expressed in milliseconds. Matches the duration +# strings the Terraform provider accepts for the same setting. +_TTL_SUFFIX_MS: dict[str, int] = { + "ms": 1, + "s": 1_000, + "m": 60 * 1_000, + "h": 60 * 60 * 1_000, + "d": 24 * 60 * 60 * 1_000, + "w": 7 * 24 * 60 * 60 * 1_000, + # "month" is approximated as 30 days, matching the provider's + # convention. Use exact day counts (``90d``) when you need precision. + "mo": 30 * 24 * 60 * 60 * 1_000, + "y": 365 * 24 * 60 * 60 * 1_000, +} + +# Tuple form ordered by suffix length DESCENDING so "mo" wins over "m" +# during prefix matching. +_TTL_SUFFIXES_ORDERED = sorted(_TTL_SUFFIX_MS.keys(), key=len, reverse=True) + +_TTL_RE = re.compile(r"^\s*(\d+)\s*([a-zA-Z]+)\s*$") + + +def parse_ttl_to_ms(value: str) -> int: + """Parse a duration string like ``"2y"``, ``"30d"``, ``"6mo"`` or + ``"500ms"`` into milliseconds. + + Accepted suffixes: ``ms`` (milliseconds), ``s`` (seconds), + ``m`` (minutes), ``h`` (hours), ``d`` (days), ``w`` (weeks), + ``mo`` (months — approximated as 30 days), ``y`` (years — 365 days). + + Raises ``ValueError`` on malformed input or unrecognised suffix. + """ + if not isinstance(value, str): + raise ValueError(f"parse_ttl_to_ms expected str, got {type(value).__name__}") + match = _TTL_RE.match(value) + if not match: + raise ValueError( + f"could not parse TTL string {value!r}; expected '' " + "where unit is one of: ms, s, m, h, d, w, mo, y" + ) + number = int(match.group(1)) + suffix = match.group(2).lower() + # Try longest-first so "mo" matches before "m". + for candidate in _TTL_SUFFIXES_ORDERED: + if suffix == candidate: + return number * _TTL_SUFFIX_MS[candidate] + raise ValueError( + f"unrecognised TTL unit {match.group(2)!r}; expected one of: " + "ms, s, m, h, d, w, mo, y" + ) + + +__all__ = [ + "DEFAULT_MAX_TTL_MS", + "OrgTokenTTLPolicy", + "OrgTokenTTLPolicyUpdateOptions", + "TokenPolicyType", + "parse_ttl_to_ms", +] diff --git a/src/pytfe/models/organization.py b/src/pytfe/models/organization.py index 4616d979..93938138 100644 --- a/src/pytfe/models/organization.py +++ b/src/pytfe/models/organization.py @@ -5,17 +5,34 @@ from datetime import datetime from enum import Enum +from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator class OrganizationUpdateOptions(BaseModel): + # populate_by_name lets existing callers keep passing snake_case + # kwargs while we add aliases that produce the correct hyphenated + # JSON:API wire names on dump. The resource layer now uses + # ``model_dump(by_alias=True, ...)`` to honour those aliases. + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + name: str | None = None email: str | None = None assessments_enforced: bool | None = None collaborator_auth_policy: str | None = None cost_estimation_enabled: bool | None = None - default_execution_mode: str | None = None + default_execution_mode: str | None = Field( + default=None, alias="default-execution-mode" + ) + # Sent as a flat ``default-agent-pool-id`` attribute on PATCH; not the + # ``default-agent-pool`` relationship shape that reads return. + default_agent_pool_id: str | None = Field( + default=None, alias="default-agent-pool-id" + ) + # Controls whether the org enforces the per-token TTL policy described + # by the new client.organization_token_ttl_policies resource. + max_ttl_enabled: bool | None = Field(default=None, alias="max-ttl-enabled") external_id: str | None = None is_unified: bool | None = None owners_team_saml_role_id: str | None = None @@ -36,12 +53,20 @@ class OrganizationUpdateOptions(BaseModel): class OrganizationCreateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + name: str | None = None email: str | None = None assessments_enforced: bool | None = None collaborator_auth_policy: str | None = None cost_estimation_enabled: bool | None = None - default_execution_mode: str | None = None + default_execution_mode: str | None = Field( + default=None, alias="default-execution-mode" + ) + default_agent_pool_id: str | None = Field( + default=None, alias="default-agent-pool-id" + ) + max_ttl_enabled: bool | None = Field(default=None, alias="max-ttl-enabled") external_id: str | None = None is_unified: bool | None = None owners_team_saml_role_id: str | None = None @@ -76,12 +101,17 @@ class RunStatus(str, Enum): class Organization(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + name: str | None = None assessments_enforced: bool | None = None collaborator_auth_policy: str | None = None cost_estimation_enabled: bool | None = None created_at: datetime | None = None - default_execution_mode: str | None = None + default_execution_mode: str | None = Field( + default=None, alias="default-execution-mode" + ) + max_ttl_enabled: bool | None = Field(default=None, alias="max-ttl-enabled") email: str | None = None external_id: str | None = None id: str | None = None @@ -99,11 +129,106 @@ class Organization(BaseModel): aggregated_commit_status_enabled: bool | None = None allow_force_delete_workspaces: bool | None = None default_project: dict | None = None + # ``default_agent_pool`` arrives as a JSON:API relationship at read time + # (under ``relationships.default-agent-pool.data.id``). The resource + # layer lifts that into this dict-shaped field; the modelling remains + # loose because callers normally only need the id string. default_agent_pool: dict | None = None data_retention_policy: dict | None = None data_retention_policy_choice: dict | None = None +# --------------------------------------------------------------------------- +# Organization default settings (provider-parity focused models) +# --------------------------------------------------------------------------- + + +class OrganizationDefaultSettings(BaseModel): + """Focused read-model for an organisation's default execution mode + + default agent pool, mirroring the provider's + ``tfe_organization_default_settings`` resource. This is a thin + projection of the underlying ``Organization`` model — both share the + same upstream endpoint (``GET /api/v2/organizations/{org}``). + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str | None = None + default_execution_mode: str | None = Field( + default=None, alias="default-execution-mode" + ) + # Lifted from ``relationships.default-agent-pool.data.id`` at parse + # time, not from ``attributes``. ``None`` means the org has no + # default agent pool configured. + default_agent_pool_id: str | None = None + + +class OrganizationDefaultSettingsUpdateOptions(BaseModel): + """Focused write-model for setting default execution mode + default + agent pool. Both fields are optional individually; the validator + below enforces the cross-field constraint that the API itself + documents. + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + default_execution_mode: str | None = Field( + default=None, alias="default-execution-mode" + ) + default_agent_pool_id: str | None = Field( + default=None, alias="default-agent-pool-id" + ) + + @model_validator(mode="after") + def _agent_pool_requires_agent_mode( + self, + ) -> OrganizationDefaultSettingsUpdateOptions: + """``default-agent-pool-id`` is only meaningful when + ``default-execution-mode == "agent"``. The upstream docs say to + not specify it for ``remote``/``local`` modes. Enforce that + locally so callers see the mistake at construction time, not as + an opaque server-side 422. + + We only enforce the constraint when both fields are present in + the same call — that lets an isolated update like + ``OrganizationDefaultSettingsUpdateOptions(default_execution_mode="remote")`` + clear the mode without forcing the caller to also explicitly + null the agent pool. + """ + if ( + self.default_agent_pool_id is not None + and self.default_execution_mode is not None + and self.default_execution_mode != "agent" + ): + raise ValueError( + "default_agent_pool_id is only valid when " + "default_execution_mode='agent'; got " + f"default_execution_mode={self.default_execution_mode!r}" + ) + return self + + def to_payload(self) -> dict[str, Any]: + """Build the JSON:API ``attributes`` dict, emitting only the + fields the caller explicitly set. Distinguishes "omit" from + "explicit None" by inspecting Pydantic's ``model_fields_set`` — + so callers can pass ``default_agent_pool_id=None`` to clear a + previously-set agent pool, while merely omitting the kwarg + leaves the server value untouched. + """ + attrs: dict[str, Any] = {} + set_fields = self.model_fields_set + if "default_execution_mode" in set_fields: + # Treat None as "leave alone" rather than "send null" here, + # because the API has no documented null-write behaviour for + # execution mode. + if self.default_execution_mode is not None: + attrs["default-execution-mode"] = self.default_execution_mode + if "default_agent_pool_id" in set_fields: + # None becomes wire null, which clears the agent pool. + attrs["default-agent-pool-id"] = self.default_agent_pool_id + return attrs + + class Capacity(BaseModel): organization: str pending: int diff --git a/src/pytfe/resources/admin/__init__.py b/src/pytfe/resources/admin/__init__.py new file mode 100644 index 00000000..c32678ad --- /dev/null +++ b/src/pytfe/resources/admin/__init__.py @@ -0,0 +1,33 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from ..._http import HTTPTransport +from ._organizations import _AdminOrganizations +from ._runs import _AdminRuns +from ._saml import _AdminSAMLSettings +from ._scim import _AdminSCIMSettings, _AdminSCIMTokens +from ._smtp import _AdminSMTPSettings +from ._users import _AdminUsers +from ._versions import ( + _AdminOpaVersions, + _AdminSentinelVersions, + _AdminTerraformVersions, +) +from ._workspaces import _AdminWorkspaces + + +class AdminClient: + def __init__(self, transport: HTTPTransport) -> None: + self.saml_settings = _AdminSAMLSettings(transport) + self.scim_settings = _AdminSCIMSettings(transport) + self.scim_tokens = _AdminSCIMTokens(transport) + self.smtp_settings = _AdminSMTPSettings(transport) + self.terraform_versions = _AdminTerraformVersions(transport) + self.opa_versions = _AdminOpaVersions(transport) + self.sentinel_versions = _AdminSentinelVersions(transport) + self.runs = _AdminRuns(transport) + self.organizations = _AdminOrganizations(transport) + self.users = _AdminUsers(transport) + self.workspaces = _AdminWorkspaces(transport) diff --git a/src/pytfe/resources/admin/_organizations.py b/src/pytfe/resources/admin/_organizations.py new file mode 100644 index 00000000..46f7074c --- /dev/null +++ b/src/pytfe/resources/admin/_organizations.py @@ -0,0 +1,62 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from ...errors import ERR_INVALID_NAME +from ...models.admin_organization import ( + AdminOrganization, + AdminOrganizationListOptions, + AdminOrganizationUpdateOptions, +) +from ...utils import valid_string_id +from .._base import _Service + +_ADMIN_ORG_TYPE = "organizations" + + +def _parse_admin_organization(data: dict[str, Any]) -> AdminOrganization: + attrs = data.get("attributes") or {} + return AdminOrganization.model_validate({"id": data.get("id"), **attrs}) + + +class _AdminOrganizations(_Service): + def list( + self, options: AdminOrganizationListOptions | None = None + ) -> Iterator[AdminOrganization]: + params: dict[str, Any] = {} + if options: + if options.query: + params["q"] = options.query + if options.page_number is not None: + params["page[number]"] = options.page_number + if options.page_size is not None: + params["page[size]"] = options.page_size + for item in self._list("/api/v2/admin/organizations", params=params): + yield _parse_admin_organization(item) + + def read(self, name: str) -> AdminOrganization: + if not valid_string_id(name): + raise ValueError(ERR_INVALID_NAME) + r = self.t.request("GET", f"/api/v2/admin/organizations/{name}") + return _parse_admin_organization(r.json()["data"]) + + def update( + self, name: str, options: AdminOrganizationUpdateOptions + ) -> AdminOrganization: + if not valid_string_id(name): + raise ValueError(ERR_INVALID_NAME) + attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") + body = {"data": {"type": _ADMIN_ORG_TYPE, "attributes": attrs}} + r = self.t.request( + "PATCH", f"/api/v2/admin/organizations/{name}", json_body=body + ) + return _parse_admin_organization(r.json()["data"]) + + def delete(self, name: str) -> None: + if not valid_string_id(name): + raise ValueError(ERR_INVALID_NAME) + self.t.request("DELETE", f"/api/v2/admin/organizations/{name}") diff --git a/src/pytfe/resources/admin/_runs.py b/src/pytfe/resources/admin/_runs.py new file mode 100644 index 00000000..db79335c --- /dev/null +++ b/src/pytfe/resources/admin/_runs.py @@ -0,0 +1,49 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from ...errors import ERR_INVALID_NAME +from ...models.admin_run import AdminRun, AdminRunListOptions +from ...utils import valid_string_id +from .._base import _Service + + +def _parse_admin_run(data: dict[str, Any]) -> AdminRun: + attrs = data.get("attributes") or {} + rels = data.get("relationships") or {} + ws_data = (rels.get("workspace") or {}).get("data") or {} + # organization is a compound include (workspace.organization) — only + # present when the caller passes ?include=workspace.organization. + # We don't surface that parameter yet, so organization_name stays None. + return AdminRun.model_validate( + { + "id": data.get("id"), + "workspace_id": ws_data.get("id"), + **attrs, + } + ) + + +class _AdminRuns(_Service): + def list(self, options: AdminRunListOptions | None = None) -> Iterator[AdminRun]: + params: dict[str, Any] = {} + if options: + if options.run_status: + params["filter[status]"] = options.run_status + if options.query: + params["q"] = options.query + if options.page_number is not None: + params["page[number]"] = options.page_number + if options.page_size is not None: + params["page[size]"] = options.page_size + for item in self._list("/api/v2/admin/runs", params=params): + yield _parse_admin_run(item) + + def force_cancel(self, run_id: str) -> None: + if not valid_string_id(run_id): + raise ValueError(ERR_INVALID_NAME) + self.t.request("POST", f"/api/v2/admin/runs/{run_id}/actions/force-cancel") diff --git a/src/pytfe/resources/admin/_saml.py b/src/pytfe/resources/admin/_saml.py new file mode 100644 index 00000000..40a14ee2 --- /dev/null +++ b/src/pytfe/resources/admin/_saml.py @@ -0,0 +1,42 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from typing import Any, TypeVar + +from pydantic import BaseModel + +from ...models.admin_identity import ( + AdminSAMLSettings, + AdminSAMLSettingsUpdateOptions, +) +from .._base import _Service + +_SAML_TYPE = "saml-settings" + +_M = TypeVar("_M", bound=BaseModel) + + +def _parse_jsonapi(data: dict[str, Any], model: type[_M]) -> _M: + attrs = data.get("attributes") or {} + return model.model_validate({"id": data.get("id"), **attrs}) + + +class _AdminSAMLSettings(_Service): + def read(self) -> AdminSAMLSettings: + r = self.t.request("GET", "/api/v2/admin/saml-settings") + return _parse_jsonapi(r.json()["data"], AdminSAMLSettings) + + def update(self, options: AdminSAMLSettingsUpdateOptions) -> AdminSAMLSettings: + attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") + body = {"data": {"type": _SAML_TYPE, "attributes": attrs}} + r = self.t.request("PATCH", "/api/v2/admin/saml-settings", json_body=body) + return _parse_jsonapi(r.json()["data"], AdminSAMLSettings) + + def revoke_idp_cert(self) -> AdminSAMLSettings: + r = self.t.request( + "POST", + "/api/v2/admin/saml-settings/actions/revoke-old-certificate", + ) + return _parse_jsonapi(r.json()["data"], AdminSAMLSettings) diff --git a/src/pytfe/resources/admin/_scim.py b/src/pytfe/resources/admin/_scim.py new file mode 100644 index 00000000..943c6fcb --- /dev/null +++ b/src/pytfe/resources/admin/_scim.py @@ -0,0 +1,84 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any, TypeVar + +from pydantic import BaseModel + +from ...errors import ( + InvalidSCIMTokenIDError, + RequiredSCIMTokenDescriptionError, +) +from ...models.admin_identity import ( + AdminSCIMSettings, + AdminSCIMSettingsUpdateOptions, + AdminSCIMToken, + AdminSCIMTokenCreateOptions, +) +from ...utils import valid_string, valid_string_id +from .._base import _Service + +_SCIM_SETTINGS_TYPE = "scim-settings" +# The TFE API uses the generic JSON:API type ``authentication-tokens`` +# for SCIM tokens; the endpoint path namespaces them under /admin/scim-tokens +# but the resource type string in the body is the shared one. +_SCIM_TOKEN_TYPE = "authentication-tokens" + +_M = TypeVar("_M", bound=BaseModel) + + +def _parse_jsonapi(data: dict[str, Any], model: type[_M]) -> _M: + attrs = data.get("attributes") or {} + return model.model_validate({"id": data.get("id"), **attrs}) + + +class _AdminSCIMSettings(_Service): + def read(self) -> AdminSCIMSettings: + r = self.t.request("GET", "/api/v2/admin/scim-settings") + return _parse_jsonapi(r.json()["data"], AdminSCIMSettings) + + def update(self, options: AdminSCIMSettingsUpdateOptions) -> AdminSCIMSettings: + body = { + "data": { + "type": _SCIM_SETTINGS_TYPE, + "attributes": options.to_payload(), + } + } + r = self.t.request("PATCH", "/api/v2/admin/scim-settings", json_body=body) + return _parse_jsonapi(r.json()["data"], AdminSCIMSettings) + + def delete(self) -> None: + self.t.request("DELETE", "/api/v2/admin/scim-settings") + + +class _AdminSCIMTokens(_Service): + def list(self) -> Iterator[AdminSCIMToken]: + # The upstream endpoint is not documented as paginated, but the + # response is still a JSON:API list. We use a single GET and + # iterate the returned ``data`` array rather than the generic + # ``self._list`` helper which adds ``page[]`` params. + r = self.t.request("GET", "/api/v2/admin/scim-tokens") + for item in r.json().get("data") or []: + yield _parse_jsonapi(item, AdminSCIMToken) + + def create(self, options: AdminSCIMTokenCreateOptions) -> AdminSCIMToken: + if not valid_string(options.description): + raise RequiredSCIMTokenDescriptionError() + attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") + body = {"data": {"type": _SCIM_TOKEN_TYPE, "attributes": attrs}} + r = self.t.request("POST", "/api/v2/admin/scim-tokens", json_body=body) + return _parse_jsonapi(r.json()["data"], AdminSCIMToken) + + def read(self, scim_token_id: str) -> AdminSCIMToken: + if not valid_string_id(scim_token_id): + raise InvalidSCIMTokenIDError() + r = self.t.request("GET", f"/api/v2/admin/scim-tokens/{scim_token_id}") + return _parse_jsonapi(r.json()["data"], AdminSCIMToken) + + def delete(self, scim_token_id: str) -> None: + if not valid_string_id(scim_token_id): + raise InvalidSCIMTokenIDError() + self.t.request("DELETE", f"/api/v2/admin/scim-tokens/{scim_token_id}") diff --git a/src/pytfe/resources/admin/_smtp.py b/src/pytfe/resources/admin/_smtp.py new file mode 100644 index 00000000..e0ca5a01 --- /dev/null +++ b/src/pytfe/resources/admin/_smtp.py @@ -0,0 +1,35 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from typing import Any, TypeVar + +from pydantic import BaseModel + +from ...models.admin_identity import ( + AdminSMTPSettings, + AdminSMTPSettingsUpdateOptions, +) +from .._base import _Service + +_SMTP_TYPE = "smtp-settings" + +_M = TypeVar("_M", bound=BaseModel) + + +def _parse_jsonapi(data: dict[str, Any], model: type[_M]) -> _M: + attrs = data.get("attributes") or {} + return model.model_validate({"id": data.get("id"), **attrs}) + + +class _AdminSMTPSettings(_Service): + def read(self) -> AdminSMTPSettings: + r = self.t.request("GET", "/api/v2/admin/smtp-settings") + return _parse_jsonapi(r.json()["data"], AdminSMTPSettings) + + def update(self, options: AdminSMTPSettingsUpdateOptions) -> AdminSMTPSettings: + attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") + body = {"data": {"type": _SMTP_TYPE, "attributes": attrs}} + r = self.t.request("PATCH", "/api/v2/admin/smtp-settings", json_body=body) + return _parse_jsonapi(r.json()["data"], AdminSMTPSettings) diff --git a/src/pytfe/resources/admin/_users.py b/src/pytfe/resources/admin/_users.py new file mode 100644 index 00000000..3c9bf6e3 --- /dev/null +++ b/src/pytfe/resources/admin/_users.py @@ -0,0 +1,80 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from ...errors import ERR_INVALID_NAME +from ...models.admin_user import AdminUser, AdminUserListOptions +from ...utils import valid_string_id +from .._base import _Service + + +def _parse_admin_user(data: dict[str, Any]) -> AdminUser: + attrs = data.get("attributes") or {} + return AdminUser.model_validate({"id": data.get("id"), **attrs}) + + +class _AdminUsers(_Service): + def list(self, options: AdminUserListOptions | None = None) -> Iterator[AdminUser]: + params: dict[str, Any] = {} + if options: + if options.query: + params["q"] = options.query + if options.administrators is not None: + params["filter[admin]"] = str(options.administrators).lower() + if options.suspended is not None: + params["filter[suspended]"] = str(options.suspended).lower() + if options.page_number is not None: + params["page[number]"] = options.page_number + if options.page_size is not None: + params["page[size]"] = options.page_size + for item in self._list("/api/v2/admin/users", params=params): + yield _parse_admin_user(item) + + def read(self, user_id: str) -> AdminUser: + if not valid_string_id(user_id): + raise ValueError(ERR_INVALID_NAME) + r = self.t.request("GET", f"/api/v2/admin/users/{user_id}") + return _parse_admin_user(r.json()["data"]) + + def delete(self, user_id: str) -> None: + if not valid_string_id(user_id): + raise ValueError(ERR_INVALID_NAME) + self.t.request("DELETE", f"/api/v2/admin/users/{user_id}") + + def suspend(self, user_id: str) -> AdminUser: + if not valid_string_id(user_id): + raise ValueError(ERR_INVALID_NAME) + r = self.t.request("POST", f"/api/v2/admin/users/{user_id}/actions/suspend") + return _parse_admin_user(r.json()["data"]) + + def unsuspend(self, user_id: str) -> AdminUser: + if not valid_string_id(user_id): + raise ValueError(ERR_INVALID_NAME) + r = self.t.request("POST", f"/api/v2/admin/users/{user_id}/actions/unsuspend") + return _parse_admin_user(r.json()["data"]) + + def grant_admin(self, user_id: str) -> AdminUser: + if not valid_string_id(user_id): + raise ValueError(ERR_INVALID_NAME) + r = self.t.request("POST", f"/api/v2/admin/users/{user_id}/actions/grant_admin") + return _parse_admin_user(r.json()["data"]) + + def revoke_admin(self, user_id: str) -> AdminUser: + if not valid_string_id(user_id): + raise ValueError(ERR_INVALID_NAME) + r = self.t.request( + "POST", f"/api/v2/admin/users/{user_id}/actions/revoke_admin" + ) + return _parse_admin_user(r.json()["data"]) + + def disable_two_factor(self, user_id: str) -> AdminUser: + if not valid_string_id(user_id): + raise ValueError(ERR_INVALID_NAME) + r = self.t.request( + "POST", f"/api/v2/admin/users/{user_id}/actions/disable_two_factor" + ) + return _parse_admin_user(r.json()["data"]) diff --git a/src/pytfe/resources/admin/_versions.py b/src/pytfe/resources/admin/_versions.py new file mode 100644 index 00000000..5d639f6a --- /dev/null +++ b/src/pytfe/resources/admin/_versions.py @@ -0,0 +1,144 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from ...errors import ERR_INVALID_VERSION +from ...models.admin_version import ( + OpaVersion, + OpaVersionCreateOptions, + OpaVersionUpdateOptions, + SentinelVersion, + SentinelVersionCreateOptions, + SentinelVersionUpdateOptions, + TerraformVersion, + TerraformVersionCreateOptions, + TerraformVersionUpdateOptions, +) +from ...utils import valid_string_id +from .._base import _Service + +_TF_VERSION_TYPE = "terraform-versions" +_OPA_VERSION_TYPE = "opa-versions" +_SENTINEL_VERSION_TYPE = "sentinel-versions" + + +def _parse_terraform_version(data: dict[str, Any]) -> TerraformVersion: + attrs = data.get("attributes") or {} + return TerraformVersion.model_validate({"id": data.get("id"), **attrs}) + + +def _parse_opa_version(data: dict[str, Any]) -> OpaVersion: + attrs = data.get("attributes") or {} + return OpaVersion.model_validate({"id": data.get("id"), **attrs}) + + +def _parse_sentinel_version(data: dict[str, Any]) -> SentinelVersion: + attrs = data.get("attributes") or {} + return SentinelVersion.model_validate({"id": data.get("id"), **attrs}) + + +class _AdminTerraformVersions(_Service): + def list(self) -> Iterator[TerraformVersion]: + for item in self._list("/api/v2/admin/terraform-versions"): + yield _parse_terraform_version(item) + + def read(self, version_id: str) -> TerraformVersion: + if not valid_string_id(version_id): + raise ValueError(ERR_INVALID_VERSION) + r = self.t.request("GET", f"/api/v2/admin/terraform-versions/{version_id}") + return _parse_terraform_version(r.json()["data"]) + + def create(self, options: TerraformVersionCreateOptions) -> TerraformVersion: + attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") + body = {"data": {"type": _TF_VERSION_TYPE, "attributes": attrs}} + r = self.t.request("POST", "/api/v2/admin/terraform-versions", json_body=body) + return _parse_terraform_version(r.json()["data"]) + + def update( + self, version_id: str, options: TerraformVersionUpdateOptions + ) -> TerraformVersion: + if not valid_string_id(version_id): + raise ValueError(ERR_INVALID_VERSION) + attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") + body = {"data": {"type": _TF_VERSION_TYPE, "attributes": attrs}} + r = self.t.request( + "PATCH", f"/api/v2/admin/terraform-versions/{version_id}", json_body=body + ) + return _parse_terraform_version(r.json()["data"]) + + def delete(self, version_id: str) -> None: + if not valid_string_id(version_id): + raise ValueError(ERR_INVALID_VERSION) + self.t.request("DELETE", f"/api/v2/admin/terraform-versions/{version_id}") + + +class _AdminOpaVersions(_Service): + def list(self) -> Iterator[OpaVersion]: + for item in self._list("/api/v2/admin/opa-versions"): + yield _parse_opa_version(item) + + def read(self, version_id: str) -> OpaVersion: + if not valid_string_id(version_id): + raise ValueError(ERR_INVALID_VERSION) + r = self.t.request("GET", f"/api/v2/admin/opa-versions/{version_id}") + return _parse_opa_version(r.json()["data"]) + + def create(self, options: OpaVersionCreateOptions) -> OpaVersion: + attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") + body = {"data": {"type": _OPA_VERSION_TYPE, "attributes": attrs}} + r = self.t.request("POST", "/api/v2/admin/opa-versions", json_body=body) + return _parse_opa_version(r.json()["data"]) + + def update(self, version_id: str, options: OpaVersionUpdateOptions) -> OpaVersion: + if not valid_string_id(version_id): + raise ValueError(ERR_INVALID_VERSION) + attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") + body = {"data": {"type": _OPA_VERSION_TYPE, "attributes": attrs}} + r = self.t.request( + "PATCH", f"/api/v2/admin/opa-versions/{version_id}", json_body=body + ) + return _parse_opa_version(r.json()["data"]) + + def delete(self, version_id: str) -> None: + if not valid_string_id(version_id): + raise ValueError(ERR_INVALID_VERSION) + self.t.request("DELETE", f"/api/v2/admin/opa-versions/{version_id}") + + +class _AdminSentinelVersions(_Service): + def list(self) -> Iterator[SentinelVersion]: + for item in self._list("/api/v2/admin/sentinel-versions"): + yield _parse_sentinel_version(item) + + def read(self, version_id: str) -> SentinelVersion: + if not valid_string_id(version_id): + raise ValueError(ERR_INVALID_VERSION) + r = self.t.request("GET", f"/api/v2/admin/sentinel-versions/{version_id}") + return _parse_sentinel_version(r.json()["data"]) + + def create(self, options: SentinelVersionCreateOptions) -> SentinelVersion: + attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") + body = {"data": {"type": _SENTINEL_VERSION_TYPE, "attributes": attrs}} + r = self.t.request("POST", "/api/v2/admin/sentinel-versions", json_body=body) + return _parse_sentinel_version(r.json()["data"]) + + def update( + self, version_id: str, options: SentinelVersionUpdateOptions + ) -> SentinelVersion: + if not valid_string_id(version_id): + raise ValueError(ERR_INVALID_VERSION) + attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") + body = {"data": {"type": _SENTINEL_VERSION_TYPE, "attributes": attrs}} + r = self.t.request( + "PATCH", f"/api/v2/admin/sentinel-versions/{version_id}", json_body=body + ) + return _parse_sentinel_version(r.json()["data"]) + + def delete(self, version_id: str) -> None: + if not valid_string_id(version_id): + raise ValueError(ERR_INVALID_VERSION) + self.t.request("DELETE", f"/api/v2/admin/sentinel-versions/{version_id}") diff --git a/src/pytfe/resources/admin/_workspaces.py b/src/pytfe/resources/admin/_workspaces.py new file mode 100644 index 00000000..43757cd4 --- /dev/null +++ b/src/pytfe/resources/admin/_workspaces.py @@ -0,0 +1,51 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from ...errors import ERR_INVALID_NAME +from ...models.admin_workspace import AdminWorkspace, AdminWorkspaceListOptions +from ...utils import valid_string_id +from .._base import _Service + + +def _parse_admin_workspace(data: dict[str, Any]) -> AdminWorkspace: + attrs = data.get("attributes") or {} + rels = data.get("relationships") or {} + org_data = (rels.get("organization") or {}).get("data") or {} + run_data = (rels.get("current-run") or {}).get("data") or {} + vcs_repo = attrs.pop("vcs-repo", None) or {} + return AdminWorkspace.model_validate( + { + "id": data.get("id"), + "organization_name": org_data.get("id"), + "current_run_id": run_data.get("id"), + "vcs_repo_identifier": vcs_repo.get("identifier") if vcs_repo else None, + **attrs, + } + ) + + +class _AdminWorkspaces(_Service): + def list( + self, options: AdminWorkspaceListOptions | None = None + ) -> Iterator[AdminWorkspace]: + params: dict[str, Any] = {} + if options: + if options.query: + params["q"] = options.query + if options.page_number is not None: + params["page[number]"] = options.page_number + if options.page_size is not None: + params["page[size]"] = options.page_size + for item in self._list("/api/v2/admin/workspaces", params=params): + yield _parse_admin_workspace(item) + + def read(self, workspace_id: str) -> AdminWorkspace: + if not valid_string_id(workspace_id): + raise ValueError(ERR_INVALID_NAME) + r = self.t.request("GET", f"/api/v2/admin/workspaces/{workspace_id}") + return _parse_admin_workspace(r.json()["data"]) diff --git a/src/pytfe/resources/admin/settings.py b/src/pytfe/resources/admin/settings.py deleted file mode 100644 index 0242f6aa..00000000 --- a/src/pytfe/resources/admin/settings.py +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright IBM Corp. 2025, 2026 -# SPDX-License-Identifier: MPL-2.0 - -from __future__ import annotations - -from typing import Any - -from .._base import _Service - - -class AdminSettings(_Service): - def terraform_versions(self) -> Any: - r = self.t.request("GET", "/api/v2/admin/terraform-versions") - return r.json() diff --git a/src/pytfe/resources/github_app_installation.py b/src/pytfe/resources/github_app_installation.py new file mode 100644 index 00000000..bcab9ca9 --- /dev/null +++ b/src/pytfe/resources/github_app_installation.py @@ -0,0 +1,62 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""GitHub App installation discovery resource. + +Read-only lookup of GitHub App installations the authenticated user can +see on HCP Terraform. Used to discover the ``github-app-installation-id`` +value that workspace/stack/registry-module VCS configuration takes. +The App authorisation itself happens in the HCP Terraform UI; this +resource only exposes the lookup. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from ..errors import InvalidGitHubAppInstallationIDError +from ..models.github_app_installation import ( + GitHubAppInstallation, + GitHubAppInstallationListOptions, +) +from ..utils import valid_string_id +from ._base import _Service + + +def _parse(data: dict[str, Any]) -> GitHubAppInstallation: + attrs = data.get("attributes") or {} + return GitHubAppInstallation.model_validate({"id": data.get("id"), **attrs}) + + +class GitHubAppInstallations(_Service): + """Resource for ``/api/v2/github-app/installations`` (list) and + ``/api/v2/github-app/installation/{id}`` (read — singular ``installation``). + """ + + def list( + self, options: GitHubAppInstallationListOptions | None = None + ) -> Iterator[GitHubAppInstallation]: + # Endpoint is not documented as paginated; we fetch a single page + # and yield from it rather than going through the paginating + # ``self._list`` helper which would add unwanted page[] params. + params = ( + options.model_dump(by_alias=True, exclude_none=True, mode="json") + if options + else None + ) + r = self.t.request("GET", "/api/v2/github-app/installations", params=params) + for item in r.json().get("data") or []: + yield _parse(item) + + def read(self, github_app_installation_id: str) -> GitHubAppInstallation: + if not valid_string_id(github_app_installation_id): + raise InvalidGitHubAppInstallationIDError() + # Note: read uses the singular path segment ``installation`` (not + # the plural ``installations`` that list uses). This is the + # documented shape — not a typo. + r = self.t.request( + "GET", + f"/api/v2/github-app/installation/{github_app_installation_id}", + ) + return _parse(r.json()["data"]) diff --git a/src/pytfe/resources/org_token_ttl_policy.py b/src/pytfe/resources/org_token_ttl_policy.py new file mode 100644 index 00000000..8f6d0c7f --- /dev/null +++ b/src/pytfe/resources/org_token_ttl_policy.py @@ -0,0 +1,89 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Organisation API-token TTL policy resource. + +Manages the maximum lifetime of API tokens minted in an organisation, +broken down by token type. Pairs with the ``max_ttl_enabled`` toggle on +the parent ``Organization`` (use +``client.organizations.update_default_settings`` or +``client.organizations.update`` to flip it on/off). +""" + +from __future__ import annotations + +import builtins +from collections.abc import Iterator +from typing import Any + +from ..errors import ERR_INVALID_ORG +from ..models.org_token_ttl_policy import ( + DEFAULT_MAX_TTL_MS, + OrgTokenTTLPolicy, + OrgTokenTTLPolicyUpdateOptions, + TokenPolicyType, +) +from ..utils import valid_string_id +from ._base import _Service + + +def _parse_policy(data: dict[str, Any]) -> OrgTokenTTLPolicy: + attrs = data.get("attributes") or {} + return OrgTokenTTLPolicy.model_validate({"id": data.get("id"), **attrs}) + + +class OrganizationTokenTTLPolicies(_Service): + """Resource for ``/api/v2/organizations/{org}/token-ttl-policies``. + + Two operations: list (one entry per token type the org has policies + for) and update (PATCH a partial set; unchanged token types keep + their existing TTLs). ``reset_to_defaults`` is a convenience that + PATCHes all four token types to the documented 2-year default. + """ + + def list(self, organization: str) -> Iterator[OrgTokenTTLPolicy]: + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + # The endpoint is not documented as paginated; we iterate the + # single returned ``data`` array directly rather than going + # through the generic paginating helper which would inject + # unwanted ``page[...]`` query params. + r = self.t.request( + "GET", f"/api/v2/organizations/{organization}/token-ttl-policies" + ) + for item in r.json().get("data") or []: + yield _parse_policy(item) + + def update( + self, + organization: str, + options: OrgTokenTTLPolicyUpdateOptions, + ) -> builtins.list[OrgTokenTTLPolicy]: + """PATCH a partial set of token-type policies. Returns the full + post-update policy list as the server reports it.""" + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + body = {"data": options.to_payload()} + r = self.t.request( + "PATCH", + f"/api/v2/organizations/{organization}/token-ttl-policies", + json_body=body, + ) + return [_parse_policy(item) for item in r.json().get("data") or []] + + def reset_to_defaults(self, organization: str) -> builtins.list[OrgTokenTTLPolicy]: + """Reset all four token types to the documented 2-year default + (``DEFAULT_MAX_TTL_MS = 63_072_000_000``). + """ + return self.update( + organization, + OrgTokenTTLPolicyUpdateOptions( + organization=DEFAULT_MAX_TTL_MS, + team=DEFAULT_MAX_TTL_MS, + user=DEFAULT_MAX_TTL_MS, + audit_trails=DEFAULT_MAX_TTL_MS, + ), + ) + + +__all__ = ["OrganizationTokenTTLPolicies", "TokenPolicyType"] diff --git a/src/pytfe/resources/organizations.py b/src/pytfe/resources/organizations.py index f9ac4b1f..a57cc3c7 100644 --- a/src/pytfe/resources/organizations.py +++ b/src/pytfe/resources/organizations.py @@ -26,6 +26,8 @@ Entitlements, Organization, OrganizationCreateOptions, + OrganizationDefaultSettings, + OrganizationDefaultSettingsUpdateOptions, OrganizationUpdateOptions, ReadRunQueueOptions, RunQueue, @@ -38,6 +40,32 @@ def _safe_str(v: Any, default: str = "") -> str: return v if isinstance(v, str) else (str(v) if v is not None else default) +def _parse_org(data: dict[str, Any]) -> Organization: + """Parse a JSON:API ``data`` block into an :class:`Organization`. + + Handles two things the legacy ``Organization(**attrs)`` shortcut + didn't: + + - Hyphenated attribute names like ``default-execution-mode`` are + accepted via the model's aliases (``populate_by_name=True``). + - The ``default-agent-pool`` relationship — which sits OUTSIDE + ``attributes`` in the JSON:API envelope — is lifted into the + ``default_agent_pool`` field as ``{"id": ""}`` so callers + can do ``org.default_agent_pool["id"]`` without traversing + relationships themselves. + """ + attrs = data.get("attributes") or {} + org_data: dict[str, Any] = dict(attrs) + org_data["id"] = _safe_str(data.get("id")) + + relationships = data.get("relationships") or {} + pool_rel = (relationships.get("default-agent-pool") or {}).get("data") + if pool_rel and pool_rel.get("id"): + org_data["default_agent_pool"] = {"id": pool_rel["id"]} + + return Organization.model_validate(org_data) + + class Organizations(_Service): def delete(self, name: str) -> None: if not valid_string_id(name): @@ -51,51 +79,112 @@ def update(self, name: str, options: OrganizationUpdateOptions) -> Organization: body = { "data": { "type": "organizations", - "attributes": options.model_dump(exclude_none=True), + # by_alias=True is required so fields with hyphenated + # JSON:API aliases (e.g. ``default-execution-mode``, + # ``default-agent-pool-id``, ``max-ttl-enabled``) reach + # the server with their wire names instead of being + # silently dropped as unknown snake_case keys. + "attributes": options.model_dump( + by_alias=True, exclude_none=True, mode="json" + ), } } r = self.t.request("PATCH", f"/api/v2/organizations/{name}", json_body=body) - d = r.json()["data"] - attr = d.get("attributes", {}) or {} - org_id = _safe_str(d.get("id")) - org_data = dict(attr) - org_data["id"] = org_id - return Organization(**org_data) + return _parse_org(r.json()["data"]) def create(self, options: OrganizationCreateOptions) -> Organization: Organizations.validate(options) body = { "data": { "type": "organizations", - "attributes": options.model_dump(exclude_none=True), + "attributes": options.model_dump( + by_alias=True, exclude_none=True, mode="json" + ), } } r = self.t.request("POST", "/api/v2/organizations", json_body=body) - d = r.json()["data"] - attr = d.get("attributes", {}) or {} - org_id = _safe_str(d.get("id")) - org_data = dict(attr) - org_data["id"] = org_id - return Organization(**org_data) + return _parse_org(r.json()["data"]) def list(self) -> Iterator[Organization]: for item in self._list("/api/v2/organizations"): - attr = item.get("attributes", {}) or {} - org_id = _safe_str(item.get("id")) - # Unpack all attributes, override id - org_data = dict(attr) - org_data["id"] = org_id - yield Organization(**org_data) + yield _parse_org(item) def read(self, name: str) -> Organization: r = self.t.request("GET", f"/api/v2/organizations/{name}") - d = r.json()["data"] - attr = d.get("attributes", {}) or {} - org_id = _safe_str(d.get("id")) - # Unpack all attributes, override id - org_data = dict(attr) - org_data["id"] = org_id - return Organization(**org_data) + return _parse_org(r.json()["data"]) + + # ---- Organization default settings (provider parity) ----------------- + # + # All three methods below hit the regular org endpoint — + # ``GET/PATCH /api/v2/organizations/{name}`` — but expose a narrower + # surface focused on ``default-execution-mode`` and the + # ``default-agent-pool`` relationship, which is how the Terraform + # provider's ``tfe_organization_default_settings`` resource models + # the same state. + + def _parse_default_settings( + self, data: dict[str, Any] + ) -> OrganizationDefaultSettings: + attrs = data.get("attributes") or {} + relationships = data.get("relationships") or {} + pool_rel = (relationships.get("default-agent-pool") or {}).get("data") + pool_id = pool_rel.get("id") if pool_rel else None + return OrganizationDefaultSettings.model_validate( + { + "id": data.get("id"), + "default-execution-mode": attrs.get("default-execution-mode"), + "default_agent_pool_id": pool_id, + } + ) + + def read_default_settings(self, organization: str) -> OrganizationDefaultSettings: + """Read the org's default execution mode and default agent pool.""" + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + r = self.t.request("GET", f"/api/v2/organizations/{organization}") + return self._parse_default_settings(r.json()["data"]) + + def update_default_settings( + self, + organization: str, + options: OrganizationDefaultSettingsUpdateOptions, + ) -> OrganizationDefaultSettings: + """Patch only the default-settings fields on the org. Cross-field + validation (``default_agent_pool_id`` requires ``agent`` execution + mode) is enforced at options construction time, not here. + """ + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + body = { + "data": { + "type": "organizations", + "attributes": options.to_payload(), + } + } + r = self.t.request( + "PATCH", f"/api/v2/organizations/{organization}", json_body=body + ) + return self._parse_default_settings(r.json()["data"]) + + def reset_default_settings(self, organization: str) -> OrganizationDefaultSettings: + """Reset to ``remote`` execution and clear any default agent pool. + + Convenience over :meth:`update_default_settings` — equivalent to + calling it with ``default_execution_mode="remote"`` and + ``default_agent_pool_id=None`` explicitly (the latter is sent as + wire ``null`` so any existing pool is unlinked). + """ + # mypy reads the Pydantic-synthesised __init__ as accepting only + # the wire-aliased kwargs (``default-execution-mode``) and not + # the Python field names. The runtime behaviour with + # ``populate_by_name=True`` accepts both; suppress here. + return self.update_default_settings( + organization, + OrganizationDefaultSettingsUpdateOptions( # type: ignore[call-arg] + default_execution_mode="remote", + default_agent_pool_id=None, + ), + ) @staticmethod def validate(opts: OrganizationCreateOptions) -> None: diff --git a/tests/units/test_admin_identity.py b/tests/units/test_admin_identity.py new file mode 100644 index 00000000..d38e941a --- /dev/null +++ b/tests/units/test_admin_identity.py @@ -0,0 +1,467 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the TFE admin identity resources (SAML, SCIM settings, +SCIM tokens) and the GitHub App installation discovery resource. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import Mock + +import pytest + +from pytfe.errors import ( + InvalidGitHubAppInstallationIDError, + InvalidSCIMTokenIDError, + RequiredSCIMTokenDescriptionError, +) +from pytfe.models.admin_identity import ( + AdminSAMLSettingsUpdateOptions, + AdminSCIMSettingsUpdateOptions, + AdminSCIMTokenCreateOptions, + SAMLProviderType, + SAMLSignatureMethod, +) +from pytfe.models.github_app_installation import ( + GitHubAppInstallationListOptions, +) +from pytfe.resources.admin import ( + AdminClient, + _AdminSAMLSettings, + _AdminSCIMSettings, + _AdminSCIMTokens, +) +from pytfe.resources.github_app_installation import GitHubAppInstallations + + +def _resp(body: Any) -> Mock: + r = Mock() + r.json.return_value = body + return r + + +# --------------------------------------------------------------------------- +# SAML settings +# --------------------------------------------------------------------------- + + +def _saml_envelope(**overrides: Any) -> dict[str, Any]: + attrs: dict[str, Any] = { + "enabled": True, + "debug": False, + "idp-cert": "cert-blob", + "old-idp-cert": None, + "slo-endpoint-url": "https://idp.example.com/slo", + "sso-endpoint-url": "https://idp.example.com/sso", + "attr-username": "Username", + "attr-groups": "MemberOf", + "attr-site-admin": "SiteAdmin", + "site-admin-role": "site-admins", + "sso-api-token-session-timeout": 1209600, + "acs-consumer-url": "https://tfe.example.com/users/saml/auth", + "metadata-url": "https://tfe.example.com/users/saml/metadata", + "authn-requests-signed": False, + "want-assertions-signed": False, + "team-management-enabled": False, + "signature-signing-method": "SHA256", + "signature-digest-method": "SHA256", + "provider-type": "saml", + "certificate": None, + } + attrs.update(overrides) + return { + "data": {"id": "saml-settings", "type": "saml-settings", "attributes": attrs} + } + + +class TestSAMLSettings: + def setup_method(self) -> None: + self.transport = Mock() + self.service = _AdminSAMLSettings(self.transport) + + def test_read(self) -> None: + self.transport.request.return_value = _resp(_saml_envelope()) + result = self.service.read() + method, path = self.transport.request.call_args.args + assert method == "GET" + assert path == "/api/v2/admin/saml-settings" + assert result.enabled is True + assert result.idp_cert == "cert-blob" + assert result.sso_api_token_session_timeout == 1209600 + assert result.provider_type == SAMLProviderType.SAML + assert result.signature_signing_method == SAMLSignatureMethod.SHA256 + + def test_update_emits_only_supplied_fields_with_wire_aliases(self) -> None: + self.transport.request.return_value = _resp(_saml_envelope()) + self.service.update( + AdminSAMLSettingsUpdateOptions( + enabled=True, + idp_cert="new-cert", + provider_type=SAMLProviderType.OKTA, + authn_requests_signed=True, + signature_signing_method=SAMLSignatureMethod.SHA256, + ) + ) + method, path = self.transport.request.call_args.args + body = self.transport.request.call_args.kwargs["json_body"] + assert method == "PATCH" + assert path == "/api/v2/admin/saml-settings" + assert body["data"]["type"] == "saml-settings" + assert body["data"]["attributes"] == { + "enabled": True, + "idp-cert": "new-cert", + "provider-type": "okta", + "authn-requests-signed": True, + "signature-signing-method": "SHA256", + } + + def test_update_omits_unset_fields(self) -> None: + self.transport.request.return_value = _resp(_saml_envelope()) + self.service.update(AdminSAMLSettingsUpdateOptions(debug=True)) + body = self.transport.request.call_args.kwargs["json_body"] + assert body["data"]["attributes"] == {"debug": True} + + def test_update_with_private_key(self) -> None: + # Private key is sensitive but must still be sent on the wire when + # the caller supplies it. The redaction is at the LOG layer, not + # the request-body layer. + self.transport.request.return_value = _resp(_saml_envelope()) + self.service.update( + AdminSAMLSettingsUpdateOptions(private_key="-----BEGIN PRIVATE-----") + ) + body = self.transport.request.call_args.kwargs["json_body"] + assert body["data"]["attributes"] == {"private-key": "-----BEGIN PRIVATE-----"} + + def test_revoke_idp_cert(self) -> None: + self.transport.request.return_value = _resp( + _saml_envelope(**{"old-idp-cert": None}) + ) + self.service.revoke_idp_cert() + method, path = self.transport.request.call_args.args + assert method == "POST" + assert path == "/api/v2/admin/saml-settings/actions/revoke-old-certificate" + + def test_invalid_provider_type_at_construction(self) -> None: + import pydantic + + with pytest.raises(pydantic.ValidationError): + AdminSAMLSettingsUpdateOptions(provider_type="garbage") # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# SCIM settings +# --------------------------------------------------------------------------- + + +class TestSCIMSettings: + def setup_method(self) -> None: + self.transport = Mock() + self.service = _AdminSCIMSettings(self.transport) + + def _envelope(self, **overrides: Any) -> dict[str, Any]: + attrs: dict[str, Any] = { + "enabled": True, + "paused": False, + "site-admin-group-scim-id": "scim-group-1", + "site-admin-group-display-name": "Site Admins", + } + attrs.update(overrides) + return { + "data": { + "id": "scim-settings", + "type": "scim-settings", + "attributes": attrs, + } + } + + def test_read(self) -> None: + self.transport.request.return_value = _resp(self._envelope()) + result = self.service.read() + method, path = self.transport.request.call_args.args + assert method == "GET" + assert path == "/api/v2/admin/scim-settings" + assert result.enabled is True + assert result.site_admin_group_scim_id == "scim-group-1" + assert result.site_admin_group_display_name == "Site Admins" + + def test_update_paused_only(self) -> None: + self.transport.request.return_value = _resp(self._envelope()) + self.service.update(AdminSCIMSettingsUpdateOptions(paused=True)) + method, path = self.transport.request.call_args.args + body = self.transport.request.call_args.kwargs["json_body"] + assert method == "PATCH" + assert path == "/api/v2/admin/scim-settings" + assert body["data"]["type"] == "scim-settings" + assert body["data"]["attributes"] == {"paused": True} + + def test_update_omits_unset_site_admin_group(self) -> None: + # Unset field MUST NOT appear in the payload — that's how the + # caller signals "leave the server value untouched". + self.transport.request.return_value = _resp(self._envelope()) + self.service.update(AdminSCIMSettingsUpdateOptions(paused=False)) + body = self.transport.request.call_args.kwargs["json_body"] + assert "site-admin-group-scim-id" not in body["data"]["attributes"] + assert body["data"]["attributes"] == {"paused": False} + + def test_update_explicit_null_site_admin_group(self) -> None: + # Explicitly passing None MUST emit JSON null on the wire — that's + # how the caller signals "unlink the SCIM group from site-admin". + # This is the crucial difference vs the omission case above. + self.transport.request.return_value = _resp(self._envelope()) + self.service.update( + AdminSCIMSettingsUpdateOptions(site_admin_group_scim_id=None) + ) + body = self.transport.request.call_args.kwargs["json_body"] + assert body["data"]["attributes"] == {"site-admin-group-scim-id": None} + + def test_update_set_site_admin_group_to_value(self) -> None: + self.transport.request.return_value = _resp(self._envelope()) + self.service.update( + AdminSCIMSettingsUpdateOptions( + paused=True, site_admin_group_scim_id="new-group-id" + ) + ) + body = self.transport.request.call_args.kwargs["json_body"] + assert body["data"]["attributes"] == { + "paused": True, + "site-admin-group-scim-id": "new-group-id", + } + + def test_delete(self) -> None: + self.transport.request.return_value = _resp({}) + self.service.delete() + method, path = self.transport.request.call_args.args + assert method == "DELETE" + assert path == "/api/v2/admin/scim-settings" + + +# --------------------------------------------------------------------------- +# SCIM tokens +# --------------------------------------------------------------------------- + + +def _token_envelope( + *, + token_id: str = "at-1", + description: str = "automation", + token: str | None = None, +) -> dict[str, Any]: + return { + "data": { + "id": token_id, + "type": "authentication-tokens", + "attributes": { + "description": description, + "token": token, + "created-at": "2026-05-29T00:00:00Z", + "expired-at": None, + "last-used-at": None, + }, + } + } + + +class TestSCIMTokens: + def setup_method(self) -> None: + self.transport = Mock() + self.service = _AdminSCIMTokens(self.transport) + + def test_list_without_pagination(self) -> None: + # The endpoint isn't documented as paginated, so the resource + # MUST NOT inject ``page[...]`` query params; a single GET is + # all that's required. + self.transport.request.return_value = _resp( + { + "data": [ + _token_envelope()["data"], + _token_envelope(token_id="at-2")["data"], + ] + } + ) + result = list(self.service.list()) + method, path = self.transport.request.call_args.args + kwargs = self.transport.request.call_args.kwargs + assert method == "GET" + assert path == "/api/v2/admin/scim-tokens" + assert "params" not in kwargs or not kwargs.get("params") + assert [t.id for t in result] == ["at-1", "at-2"] + # token field is always None on list (per the API contract). + assert all(t.token is None for t in result) + + def test_create_returns_token_value(self) -> None: + self.transport.request.return_value = _resp( + _token_envelope(token="PLAINTEXT-ONLY-RETURNED-NOW") + ) + result = self.service.create(AdminSCIMTokenCreateOptions(description="ci-bot")) + method, path = self.transport.request.call_args.args + body = self.transport.request.call_args.kwargs["json_body"] + assert method == "POST" + assert path == "/api/v2/admin/scim-tokens" + assert body["data"]["type"] == "authentication-tokens" + assert body["data"]["attributes"] == {"description": "ci-bot"} + assert result.token == "PLAINTEXT-ONLY-RETURNED-NOW" + + def test_create_requires_non_empty_description(self) -> None: + # Pydantic rejects construction with an empty string via the + # Field(...) requirement? Actually Field(...) only enforces + # presence; empty string passes. The resource layer enforces + # non-empty via valid_string and raises a typed error. + with pytest.raises(RequiredSCIMTokenDescriptionError): + self.service.create(AdminSCIMTokenCreateOptions(description="")) + + def test_read_uses_admin_path(self) -> None: + self.transport.request.return_value = _resp(_token_envelope()) + self.service.read("at-1") + method, path = self.transport.request.call_args.args + assert method == "GET" + assert path == "/api/v2/admin/scim-tokens/at-1" + + def test_delete_uses_admin_path(self) -> None: + # Crucial wire-shape test: the upstream docs specify + # /api/v2/admin/scim-tokens/{id} for DELETE, NOT the generic + # /api/v2/authentication-tokens/{id} path. Regression-protect that. + self.transport.request.return_value = _resp({}) + self.service.delete("at-1") + method, path = self.transport.request.call_args.args + assert method == "DELETE" + assert path == "/api/v2/admin/scim-tokens/at-1" + + def test_invalid_token_id_on_read(self) -> None: + with pytest.raises(InvalidSCIMTokenIDError): + self.service.read("") + + def test_invalid_token_id_on_delete(self) -> None: + with pytest.raises(InvalidSCIMTokenIDError): + self.service.delete("") + + +# --------------------------------------------------------------------------- +# AdminClient namespace facade +# --------------------------------------------------------------------------- + + +class TestAdminClient: + def test_exposes_three_nested_services(self) -> None: + admin = AdminClient(Mock()) + assert isinstance(admin.saml_settings, _AdminSAMLSettings) + assert isinstance(admin.scim_settings, _AdminSCIMSettings) + assert isinstance(admin.scim_tokens, _AdminSCIMTokens) + + +# --------------------------------------------------------------------------- +# GitHub App installations +# --------------------------------------------------------------------------- + + +def _installation_envelope( + *, + install_id: str = "ghain-1", + name: str = "my-org", + installation_id: int = 54810170, +) -> dict[str, Any]: + return { + "data": { + "id": install_id, + "type": "github-app-installations", + "attributes": { + "name": name, + "installation-id": installation_id, + "icon-url": "https://github.com/icon.png", + "installation-type": "Organization", + "installation-url": f"https://github.com/{name}", + }, + } + } + + +class TestGitHubAppInstallations: + def setup_method(self) -> None: + self.transport = Mock() + self.service = GitHubAppInstallations(self.transport) + + def test_list_no_filters(self) -> None: + self.transport.request.return_value = _resp( + { + "data": [ + _installation_envelope()["data"], + _installation_envelope(install_id="ghain-2", name="other-org")[ + "data" + ], + ] + } + ) + result = list(self.service.list()) + method, path = self.transport.request.call_args.args + kwargs = self.transport.request.call_args.kwargs + assert method == "GET" + assert path == "/api/v2/github-app/installations" + # No filter params when none supplied. + assert kwargs.get("params") is None + assert [i.id for i in result] == ["ghain-1", "ghain-2"] + assert result[0].installation_id == 54810170 + assert result[0].installation_type == "Organization" + + def test_list_with_name_filter(self) -> None: + self.transport.request.return_value = _resp({"data": []}) + list(self.service.list(GitHubAppInstallationListOptions(name="my-org"))) + params = self.transport.request.call_args.kwargs["params"] + assert params == {"filter[name]": "my-org"} + + def test_list_with_installation_id_filter(self) -> None: + self.transport.request.return_value = _resp({"data": []}) + list( + self.service.list( + GitHubAppInstallationListOptions(installation_id=54810170) + ) + ) + params = self.transport.request.call_args.kwargs["params"] + assert params == {"filter[installation_id]": 54810170} + + def test_read_uses_singular_path_segment(self) -> None: + # Singular `installation` (NOT plural `installations`) — this is + # the documented path shape. Regression-protect it. + self.transport.request.return_value = _resp(_installation_envelope()) + self.service.read("ghain-1") + method, path = self.transport.request.call_args.args + assert method == "GET" + assert path == "/api/v2/github-app/installation/ghain-1" + + def test_read_parses_all_attributes(self) -> None: + self.transport.request.return_value = _resp(_installation_envelope()) + result = self.service.read("ghain-1") + assert result.id == "ghain-1" + assert result.name == "my-org" + assert result.installation_id == 54810170 + assert result.icon_url == "https://github.com/icon.png" + assert result.installation_type == "Organization" + assert result.installation_url == "https://github.com/my-org" + + def test_invalid_id_on_read(self) -> None: + with pytest.raises(InvalidGitHubAppInstallationIDError): + self.service.read("") + + +# --------------------------------------------------------------------------- +# Logging redaction covers SAML private-key +# --------------------------------------------------------------------------- + + +class TestSAMLPrivateKeyRedaction: + def test_private_key_key_is_in_sensitive_set(self) -> None: + # Direct membership check guards against the wire-format key + # ("private-key" with hyphen) silently dropping out of the set. + from pytfe._logging import _SENSITIVE_JSON_KEYS + + assert "private-key" in _SENSITIVE_JSON_KEYS + assert "private_key" in _SENSITIVE_JSON_KEYS + + def test_certificate_fields_are_not_redacted(self) -> None: + # X.509 certs are public material by design; redacting them hurts + # debugging without protecting anything. This pins that decision. + from pytfe._logging import _SENSITIVE_JSON_KEYS + + assert "idp-cert" not in _SENSITIVE_JSON_KEYS + assert "certificate" not in _SENSITIVE_JSON_KEYS + assert "old-idp-cert" not in _SENSITIVE_JSON_KEYS diff --git a/tests/units/test_admin_resource_fixes.py b/tests/units/test_admin_resource_fixes.py new file mode 100644 index 00000000..d42f9f0a --- /dev/null +++ b/tests/units/test_admin_resource_fixes.py @@ -0,0 +1,312 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the four confirmed admin API mismatches: +1. User action endpoint paths (underscores not hyphens). +2. ToolVersionArchitecture / archs field in version models. +3. Admin run parser — workspace relationship only (organization via compound include). +4. Admin workspace — vcs_repo_identifier from attribute, no execution_mode. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +from pytfe.models.admin_run import AdminRun +from pytfe.models.admin_version import ( + OpaVersion, + OpaVersionCreateOptions, + SentinelVersion, + SentinelVersionCreateOptions, + TerraformVersion, + TerraformVersionCreateOptions, + TerraformVersionUpdateOptions, + ToolVersionArchitecture, +) +from pytfe.models.admin_workspace import AdminWorkspace +from pytfe.resources.admin._runs import _AdminRuns, _parse_admin_run +from pytfe.resources.admin._users import _AdminUsers +from pytfe.resources.admin._workspaces import _parse_admin_workspace + + +def _transport(responses: list[Any]) -> MagicMock: + t = MagicMock() + mocks = [] + for body in responses: + r = MagicMock() + r.json.return_value = body + mocks.append(r) + t.request.side_effect = mocks + return t + + +# --------------------------------------------------------------------------- +# Finding 1: user action endpoint paths use underscores +# --------------------------------------------------------------------------- + + +class TestUserActionPaths: + def _make_user_resp(self, user_id: str = "user-abc") -> dict: + return { + "data": { + "id": user_id, + "type": "users", + "attributes": { + "username": "tester", + "email": "tester@example.com", + "is-admin": False, + "is-suspended": False, + "two-factor-enabled": False, + "two-factor-verified": False, + }, + } + } + + def test_grant_admin_uses_underscore_path(self): + t = _transport([self._make_user_resp()]) + svc = _AdminUsers(t) + svc.grant_admin("user-abc") + t.request.assert_called_once_with( + "POST", "/api/v2/admin/users/user-abc/actions/grant_admin" + ) + + def test_revoke_admin_uses_underscore_path(self): + t = _transport([self._make_user_resp()]) + svc = _AdminUsers(t) + svc.revoke_admin("user-abc") + t.request.assert_called_once_with( + "POST", "/api/v2/admin/users/user-abc/actions/revoke_admin" + ) + + def test_disable_two_factor_uses_underscore_path(self): + t = _transport([self._make_user_resp()]) + svc = _AdminUsers(t) + svc.disable_two_factor("user-abc") + t.request.assert_called_once_with( + "POST", "/api/v2/admin/users/user-abc/actions/disable_two_factor" + ) + + def test_suspend_still_hyphenated(self): + t = _transport([self._make_user_resp()]) + svc = _AdminUsers(t) + svc.suspend("user-abc") + t.request.assert_called_once_with( + "POST", "/api/v2/admin/users/user-abc/actions/suspend" + ) + + def test_unsuspend_still_hyphenated(self): + t = _transport([self._make_user_resp()]) + svc = _AdminUsers(t) + svc.unsuspend("user-abc") + t.request.assert_called_once_with( + "POST", "/api/v2/admin/users/user-abc/actions/unsuspend" + ) + + +# --------------------------------------------------------------------------- +# Finding 2: ToolVersionArchitecture / archs field +# --------------------------------------------------------------------------- + + +class TestToolVersionArchitecture: + def test_architecture_model_fields(self): + arch = ToolVersionArchitecture( + url="https://example.com/tf.zip", sha="abc123", os="linux", arch="amd64" + ) + assert arch.url == "https://example.com/tf.zip" + assert arch.sha == "abc123" + assert arch.os == "linux" + assert arch.arch == "amd64" + + def test_terraform_version_has_archs(self): + arch = ToolVersionArchitecture(url="u", sha="s", os="linux", arch="amd64") + v = TerraformVersion(id="tv-1", version="1.9.0", archs=[arch]) + assert v.archs is not None + assert len(v.archs) == 1 + assert v.archs[0].arch == "amd64" + + def test_terraform_version_archs_optional(self): + v = TerraformVersion(id="tv-1", version="1.9.0", url="u", sha="s") + assert v.archs is None + + def test_create_options_include_archs(self): + arch = ToolVersionArchitecture(url="u", sha="s", os="darwin", arch="arm64") + opts = TerraformVersionCreateOptions( + version="1.9.0", url="u", sha="s", archs=[arch] + ) + dumped = opts.model_dump(by_alias=True, exclude_none=True, mode="json") + assert "archs" in dumped + assert dumped["archs"][0]["arch"] == "arm64" + + def test_update_options_include_archs(self): + arch = ToolVersionArchitecture(url="u2", sha="s2", os="windows", arch="amd64") + opts = TerraformVersionUpdateOptions(archs=[arch]) + dumped = opts.model_dump(by_alias=True, exclude_none=True, mode="json") + assert "archs" in dumped + + def test_opa_version_has_archs(self): + arch = ToolVersionArchitecture(url="u", sha="s", os="linux", arch="amd64") + v = OpaVersion(id="ov-1", version="0.60.0", archs=[arch]) + assert v.archs is not None + + def test_opa_create_options_archs(self): + arch = ToolVersionArchitecture(url="u", sha="s", os="linux", arch="amd64") + opts = OpaVersionCreateOptions(version="0.60.0", url="u", sha="s", archs=[arch]) + dumped = opts.model_dump(by_alias=True, exclude_none=True, mode="json") + assert "archs" in dumped + + def test_sentinel_version_has_archs(self): + arch = ToolVersionArchitecture(url="u", sha="s", os="linux", arch="amd64") + v = SentinelVersion(id="sv-1", version="0.26.0", archs=[arch]) + assert v.archs is not None + + def test_sentinel_create_options_archs(self): + arch = ToolVersionArchitecture(url="u", sha="s", os="linux", arch="amd64") + opts = SentinelVersionCreateOptions( + version="0.26.0", url="u", sha="s", archs=[arch] + ) + dumped = opts.model_dump(by_alias=True, exclude_none=True, mode="json") + assert "archs" in dumped + + def test_version_parsed_from_api_with_archs(self): + raw = { + "id": "tv-1", + "type": "terraform-versions", + "attributes": { + "version": "1.9.0", + "url": "https://example.com/tf.zip", + "sha": "deadbeef", + "official": True, + "enabled": True, + "beta": False, + "deprecated": False, + "usage": 5, + "archs": [ + {"url": "u1", "sha": "s1", "os": "linux", "arch": "amd64"}, + {"url": "u2", "sha": "s2", "os": "darwin", "arch": "arm64"}, + ], + }, + } + attrs = raw["attributes"] + v = TerraformVersion.model_validate({"id": raw["id"], **attrs}) + assert v.archs is not None + assert len(v.archs) == 2 + assert v.archs[1].os == "darwin" + + +# --------------------------------------------------------------------------- +# Finding 3: admin run parser — workspace rel only, no top-level org rel +# --------------------------------------------------------------------------- + + +class TestAdminRunParser: + def _make_run_data(self, run_id: str = "run-abc", ws_id: str = "ws-xyz") -> dict: + return { + "id": run_id, + "type": "runs", + "attributes": { + "status": "pending", + "has-changes": False, + "plan-only": False, + }, + "relationships": { + "workspace": {"data": {"id": ws_id, "type": "workspaces"}}, + # no top-level 'organization' key in standard response + }, + } + + def test_workspace_id_populated(self): + data = self._make_run_data(ws_id="ws-xyz") + run = _parse_admin_run(data) + assert run.workspace_id == "ws-xyz" + + def test_organization_name_none_without_include(self): + data = self._make_run_data() + run = _parse_admin_run(data) + assert run.organization_name is None + + def test_status_parsed(self): + data = self._make_run_data() + run = _parse_admin_run(data) + from pytfe.models.run import RunStatus + + assert run.status == RunStatus.Run_Pending + + def test_model_has_no_workspace_name_field(self): + assert not hasattr(AdminRun.model_fields.get("workspace_name", None), "default") + + def test_force_cancel_path(self): + t = MagicMock() + r = MagicMock() + r.json.return_value = None + t.request.return_value = r + svc = _AdminRuns(t) + svc.force_cancel("run-abc") + t.request.assert_called_once_with( + "POST", "/api/v2/admin/runs/run-abc/actions/force-cancel" + ) + + +# --------------------------------------------------------------------------- +# Finding 4: admin workspace — vcs_repo_identifier from attr, no execution_mode +# --------------------------------------------------------------------------- + + +class TestAdminWorkspaceParser: + def _make_ws_data( + self, + ws_id: str = "ws-abc", + org_id: str = "my-org", + vcs_identifier: str | None = "github/my-repo", + run_id: str | None = "run-123", + ) -> dict: + attrs: dict = {"name": "my-workspace", "locked": False} + if vcs_identifier: + attrs["vcs-repo"] = {"identifier": vcs_identifier} + rels: dict = { + "organization": {"data": {"id": org_id, "type": "organizations"}}, + } + if run_id: + rels["current-run"] = {"data": {"id": run_id, "type": "runs"}} + return { + "id": ws_id, + "type": "workspaces", + "attributes": attrs, + "relationships": rels, + } + + def test_organization_lifted_from_relationship(self): + data = self._make_ws_data(org_id="prab-org") + ws = _parse_admin_workspace(data) + assert ws.organization_name == "prab-org" + + def test_current_run_id_lifted(self): + data = self._make_ws_data(run_id="run-999") + ws = _parse_admin_workspace(data) + assert ws.current_run_id == "run-999" + + def test_vcs_repo_identifier_parsed(self): + data = self._make_ws_data(vcs_identifier="hashicorp/terraform") + ws = _parse_admin_workspace(data) + assert ws.vcs_repo_identifier == "hashicorp/terraform" + + def test_vcs_repo_none_when_absent(self): + data = self._make_ws_data(vcs_identifier=None) + ws = _parse_admin_workspace(data) + assert ws.vcs_repo_identifier is None + + def test_current_run_none_when_absent(self): + data = self._make_ws_data(run_id=None) + ws = _parse_admin_workspace(data) + assert ws.current_run_id is None + + def test_no_execution_mode_field(self): + assert "execution_mode" not in AdminWorkspace.model_fields + + def test_basic_fields(self): + data = self._make_ws_data() + ws = _parse_admin_workspace(data) + assert ws.id == "ws-abc" + assert ws.name == "my-workspace" + assert ws.locked is False diff --git a/tests/units/test_admin_smtp_and_ttl.py b/tests/units/test_admin_smtp_and_ttl.py new file mode 100644 index 00000000..b6ac5f66 --- /dev/null +++ b/tests/units/test_admin_smtp_and_ttl.py @@ -0,0 +1,513 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for SMTP admin settings, organisation default settings, and +organisation token-TTL policies. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import Mock + +import pydantic +import pytest + +from pytfe.errors import RequiredFieldMissing +from pytfe.models.admin_identity import ( + AdminSMTPSettingsUpdateOptions, + SMTPAuthType, +) +from pytfe.models.org_token_ttl_policy import ( + DEFAULT_MAX_TTL_MS, + OrgTokenTTLPolicyUpdateOptions, + TokenPolicyType, + parse_ttl_to_ms, +) +from pytfe.models.organization import ( + OrganizationDefaultSettingsUpdateOptions, +) +from pytfe.resources.admin import _AdminSMTPSettings +from pytfe.resources.org_token_ttl_policy import OrganizationTokenTTLPolicies +from pytfe.resources.organizations import Organizations + + +def _resp(body: Any) -> Mock: + r = Mock() + r.json.return_value = body + return r + + +# --------------------------------------------------------------------------- +# SMTP +# --------------------------------------------------------------------------- + + +def _smtp_envelope(**overrides: Any) -> dict[str, Any]: + attrs: dict[str, Any] = { + "enabled": True, + "host": "smtp.example.com", + "port": 587, + "sender": "noreply@example.com", + "auth": "login", + "username": "smtp-bot", + } + attrs.update(overrides) + return {"data": {"id": "smtp", "type": "smtp-settings", "attributes": attrs}} + + +class TestAdminSMTP: + def setup_method(self) -> None: + self.transport = Mock() + self.service = _AdminSMTPSettings(self.transport) + + def test_read_parses_returned_fields(self) -> None: + self.transport.request.return_value = _resp(_smtp_envelope()) + result = self.service.read() + method, path = self.transport.request.call_args.args + assert method == "GET" + assert path == "/api/v2/admin/smtp-settings" + assert result.enabled is True + assert result.host == "smtp.example.com" + assert result.port == 587 + assert result.auth == SMTPAuthType.LOGIN + # The read model deliberately doesn't surface password or + # test-email-address — those are write-only on the wire. + assert not hasattr(result, "password") + assert not hasattr(result, "test_email_address") + + def test_update_emits_only_supplied_fields(self) -> None: + self.transport.request.return_value = _resp(_smtp_envelope()) + self.service.update( + AdminSMTPSettingsUpdateOptions( + enabled=True, + host="smtp.example.com", + port=587, + auth=SMTPAuthType.PLAIN, + username="bot", + password="hunter2", + test_email_address="ops@example.com", + ) + ) + method, path = self.transport.request.call_args.args + body = self.transport.request.call_args.kwargs["json_body"] + assert method == "PATCH" + assert path == "/api/v2/admin/smtp-settings" + assert body["data"]["type"] == "smtp-settings" + assert body["data"]["attributes"] == { + "enabled": True, + "host": "smtp.example.com", + "port": 587, + "auth": "plain", + "username": "bot", + "password": "hunter2", + "test-email-address": "ops@example.com", + } + + def test_update_omits_password_when_not_set(self) -> None: + # Verify password/test-email-address are NOT sent unless the + # caller actually supplied them. + self.transport.request.return_value = _resp(_smtp_envelope()) + self.service.update(AdminSMTPSettingsUpdateOptions(host="smtp.new.example.com")) + body = self.transport.request.call_args.kwargs["json_body"] + assert body["data"]["attributes"] == {"host": "smtp.new.example.com"} + assert "password" not in body["data"]["attributes"] + assert "test-email-address" not in body["data"]["attributes"] + + def test_invalid_auth_at_construction(self) -> None: + with pytest.raises(pydantic.ValidationError): + AdminSMTPSettingsUpdateOptions(auth="oauth2") # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# Organization default settings +# --------------------------------------------------------------------------- + + +def _org_envelope( + *, + org_id: str = "my-org", + default_execution_mode: str | None = "remote", + default_agent_pool_id: str | None = None, +) -> dict[str, Any]: + attrs: dict[str, Any] = { + "name": org_id, + "default-execution-mode": default_execution_mode, + "max-ttl-enabled": False, + } + relationships: dict[str, Any] = {} + if default_agent_pool_id is not None: + relationships["default-agent-pool"] = { + "data": {"type": "agent-pools", "id": default_agent_pool_id} + } + else: + # API returns the relationship key with data=null when unset. + relationships["default-agent-pool"] = {"data": None} + return { + "data": { + "id": org_id, + "type": "organizations", + "attributes": attrs, + "relationships": relationships, + } + } + + +class TestOrganizationDefaultSettings: + def setup_method(self) -> None: + self.transport = Mock() + self.service = Organizations(self.transport) + + def test_read_default_settings_parses_attribute_and_relationship(self) -> None: + self.transport.request.return_value = _resp( + _org_envelope( + default_execution_mode="agent", + default_agent_pool_id="apool-abc123", + ) + ) + result = self.service.read_default_settings("my-org") + method, path = self.transport.request.call_args.args + assert method == "GET" + assert path == "/api/v2/organizations/my-org" + assert result.id == "my-org" + assert result.default_execution_mode == "agent" + # Crucially the pool id comes from the relationships block, not + # from attributes. + assert result.default_agent_pool_id == "apool-abc123" + + def test_read_default_settings_returns_none_when_relationship_data_null( + self, + ) -> None: + self.transport.request.return_value = _resp(_org_envelope()) + result = self.service.read_default_settings("my-org") + assert result.default_agent_pool_id is None + + def test_update_default_settings_emits_only_set_fields(self) -> None: + self.transport.request.return_value = _resp( + _org_envelope( + default_execution_mode="agent", + default_agent_pool_id="apool-abc123", + ) + ) + self.service.update_default_settings( + "my-org", + OrganizationDefaultSettingsUpdateOptions( + default_execution_mode="agent", + default_agent_pool_id="apool-abc123", + ), + ) + method, path = self.transport.request.call_args.args + body = self.transport.request.call_args.kwargs["json_body"] + assert method == "PATCH" + assert path == "/api/v2/organizations/my-org" + assert body["data"]["type"] == "organizations" + assert body["data"]["attributes"] == { + "default-execution-mode": "agent", + "default-agent-pool-id": "apool-abc123", + } + + def test_update_default_settings_explicit_null_agent_pool(self) -> None: + # Setting agent pool id to None explicitly sends wire null, which + # clears any previously-configured pool. The validator does NOT + # block this combination when mode is being reset to "remote". + self.transport.request.return_value = _resp(_org_envelope()) + self.service.update_default_settings( + "my-org", + OrganizationDefaultSettingsUpdateOptions( + default_execution_mode="remote", + default_agent_pool_id=None, + ), + ) + body = self.transport.request.call_args.kwargs["json_body"] + assert body["data"]["attributes"] == { + "default-execution-mode": "remote", + "default-agent-pool-id": None, + } + + def test_update_default_settings_validation_rejects_pool_with_non_agent_mode( + self, + ) -> None: + # Cross-field validator: setting a pool id while explicitly + # asking for remote/local mode is a local error, not a server + # round trip. + with pytest.raises(pydantic.ValidationError): + OrganizationDefaultSettingsUpdateOptions( + default_execution_mode="remote", + default_agent_pool_id="apool-abc123", + ) + + def test_update_default_settings_partial_only_mode(self) -> None: + # Updating only the execution mode (no pool) must not pass a + # default-agent-pool-id key at all — that's how the omit case + # signals "leave the server value untouched". + self.transport.request.return_value = _resp(_org_envelope()) + self.service.update_default_settings( + "my-org", + OrganizationDefaultSettingsUpdateOptions(default_execution_mode="local"), + ) + body = self.transport.request.call_args.kwargs["json_body"] + assert body["data"]["attributes"] == {"default-execution-mode": "local"} + assert "default-agent-pool-id" not in body["data"]["attributes"] + + def test_reset_default_settings(self) -> None: + self.transport.request.return_value = _resp(_org_envelope()) + self.service.reset_default_settings("my-org") + method, path = self.transport.request.call_args.args + body = self.transport.request.call_args.kwargs["json_body"] + assert method == "PATCH" + assert path == "/api/v2/organizations/my-org" + # Reset clears the pool with explicit null and sets mode to remote. + assert body["data"]["attributes"] == { + "default-execution-mode": "remote", + "default-agent-pool-id": None, + } + + +# --------------------------------------------------------------------------- +# Org options alias fix (regression test) +# --------------------------------------------------------------------------- + + +class TestOrganizationOptionsAliasFix: + """Regression coverage for the wire-shape fix on the broader + OrganizationUpdateOptions: the existing snake_case fields had no + aliases, so PATCH bodies silently dropped keys like + default_execution_mode on the server side. The fix adds aliases and + forces ``by_alias=True`` on dump. + """ + + def test_update_emits_hyphenated_keys(self) -> None: + from pytfe.models import OrganizationUpdateOptions + + transport = Mock() + transport.request.return_value = _resp(_org_envelope()) + Organizations(transport).update( + "my-org", + OrganizationUpdateOptions( + default_execution_mode="agent", + default_agent_pool_id="apool-1", + max_ttl_enabled=True, + ), + ) + body = transport.request.call_args.kwargs["json_body"] + attrs = body["data"]["attributes"] + # Wire shape MUST be hyphenated; previously these went out as + # default_execution_mode and the server ignored them. + assert "default-execution-mode" in attrs + assert attrs["default-execution-mode"] == "agent" + assert "default-agent-pool-id" in attrs + assert attrs["default-agent-pool-id"] == "apool-1" + assert "max-ttl-enabled" in attrs + assert attrs["max-ttl-enabled"] is True + # Snake_case keys MUST NOT appear (regression guard). + assert "default_execution_mode" not in attrs + assert "default_agent_pool_id" not in attrs + assert "max_ttl_enabled" not in attrs + + +# --------------------------------------------------------------------------- +# Token TTL policy duration parser +# --------------------------------------------------------------------------- + + +class TestParseTTLToMs: + @pytest.mark.parametrize( + "value,expected", + [ + ("500ms", 500), + ("1s", 1000), + ("1m", 60_000), + ("1h", 3_600_000), + ("1d", 86_400_000), + ("1w", 604_800_000), + ("1mo", 2_592_000_000), # 30 days + ("1y", 31_536_000_000), # 365 days + ("2y", DEFAULT_MAX_TTL_MS), + ("30d", 30 * 86_400_000), + (" 6mo ", 6 * 2_592_000_000), # whitespace tolerated + ], + ) + def test_valid(self, value: str, expected: int) -> None: + assert parse_ttl_to_ms(value) == expected + + def test_mo_beats_m_in_suffix_match(self) -> None: + # Critical: "6mo" must parse as months, NOT as "6m" followed by + # garbage "o". The regex captures the full alphabetic suffix and + # the longest-first match table picks "mo" over "m". + assert parse_ttl_to_ms("6mo") == 6 * 2_592_000_000 + assert parse_ttl_to_ms("6m") == 6 * 60_000 + + @pytest.mark.parametrize( + "value", + ["", "abc", "1", "y", "1xyz", "-1d", "1.5h", "one year"], + ) + def test_rejects_garbage(self, value: str) -> None: + with pytest.raises(ValueError): + parse_ttl_to_ms(value) + + +# --------------------------------------------------------------------------- +# OrgTokenTTLPolicyUpdateOptions +# --------------------------------------------------------------------------- + + +class TestOrgTokenTTLPolicyUpdateOptions: + def test_payload_uses_underscored_audit_trails_spelling(self) -> None: + # The crucial spelling distinction: the TTL policy API uses + # ``audit_trails`` (UNDERSCORE), even though other audit-trail + # token surfaces use ``audit-trails`` (HYPHEN). Pin this in a + # test so a future "consistency fix" can't silently break it. + options = OrgTokenTTLPolicyUpdateOptions(audit_trails=DEFAULT_MAX_TTL_MS) + payload = options.to_payload() + assert len(payload) == 1 + assert payload[0]["attributes"]["token-type"] == "audit_trails" + + def test_payload_full_four_token_types(self) -> None: + options = OrgTokenTTLPolicyUpdateOptions( + organization=DEFAULT_MAX_TTL_MS, + team=DEFAULT_MAX_TTL_MS, + user=DEFAULT_MAX_TTL_MS, + audit_trails=DEFAULT_MAX_TTL_MS, + ) + payload = options.to_payload() + token_types = [item["attributes"]["token-type"] for item in payload] + assert token_types == ["organization", "team", "user", "audit_trails"] + for item in payload: + assert item["type"] == "organization-token-ttl-policies" + assert item["attributes"]["max-ttl-ms"] == DEFAULT_MAX_TTL_MS + + def test_payload_accepts_duration_strings(self) -> None: + options = OrgTokenTTLPolicyUpdateOptions( + organization="2y", + team="30d", + user="1h", + ) + payload = options.to_payload() + ms_by_type = { + item["attributes"]["token-type"]: item["attributes"]["max-ttl-ms"] + for item in payload + } + assert ms_by_type == { + "organization": DEFAULT_MAX_TTL_MS, + "team": 30 * 86_400_000, + "user": 3_600_000, + } + + def test_empty_options_raises_typed_error(self) -> None: + with pytest.raises(RequiredFieldMissing): + OrgTokenTTLPolicyUpdateOptions().to_payload() + + def test_mixed_int_and_string_values(self) -> None: + options = OrgTokenTTLPolicyUpdateOptions( + organization="1y", + team=3600_000, + ) + payload = options.to_payload() + ms_by_type = { + item["attributes"]["token-type"]: item["attributes"]["max-ttl-ms"] + for item in payload + } + assert ms_by_type["organization"] == 31_536_000_000 + assert ms_by_type["team"] == 3_600_000 + + +# --------------------------------------------------------------------------- +# OrganizationTokenTTLPolicies resource +# --------------------------------------------------------------------------- + + +def _ttl_item(token_type: str, max_ttl_ms: int = DEFAULT_MAX_TTL_MS) -> dict[str, Any]: + return { + "id": f"ottp-{token_type}", + "type": "organization-token-ttl-policies", + "attributes": {"token-type": token_type, "max-ttl-ms": max_ttl_ms}, + } + + +class TestOrganizationTokenTTLPoliciesResource: + def setup_method(self) -> None: + self.transport = Mock() + self.service = OrganizationTokenTTLPolicies(self.transport) + + def test_list_url_and_no_pagination(self) -> None: + self.transport.request.return_value = _resp( + { + "data": [ + _ttl_item("organization"), + _ttl_item("team"), + _ttl_item("user"), + _ttl_item("audit_trails"), + ] + } + ) + result = list(self.service.list("my-org")) + method, path = self.transport.request.call_args.args + kwargs = self.transport.request.call_args.kwargs + assert method == "GET" + assert path == "/api/v2/organizations/my-org/token-ttl-policies" + # Endpoint is not paginated — must not inject page[] params. + assert "params" not in kwargs or not kwargs.get("params") + assert [p.token_type for p in result] == [ + TokenPolicyType.ORGANIZATION, + TokenPolicyType.TEAM, + TokenPolicyType.USER, + TokenPolicyType.AUDIT_TRAILS, + ] + + def test_update_url_and_payload(self) -> None: + self.transport.request.return_value = _resp( + {"data": [_ttl_item("team", 3_600_000)]} + ) + self.service.update( + "my-org", + OrgTokenTTLPolicyUpdateOptions(team="1h"), + ) + method, path = self.transport.request.call_args.args + body = self.transport.request.call_args.kwargs["json_body"] + assert method == "PATCH" + assert path == "/api/v2/organizations/my-org/token-ttl-policies" + assert body == { + "data": [ + { + "type": "organization-token-ttl-policies", + "attributes": {"token-type": "team", "max-ttl-ms": 3_600_000}, + } + ] + } + + def test_reset_to_defaults_sends_all_four(self) -> None: + self.transport.request.return_value = _resp( + { + "data": [ + _ttl_item("organization"), + _ttl_item("team"), + _ttl_item("user"), + _ttl_item("audit_trails"), + ] + } + ) + self.service.reset_to_defaults("my-org") + body = self.transport.request.call_args.kwargs["json_body"] + token_types = [item["attributes"]["token-type"] for item in body["data"]] + assert token_types == ["organization", "team", "user", "audit_trails"] + for item in body["data"]: + assert item["attributes"]["max-ttl-ms"] == DEFAULT_MAX_TTL_MS + + def test_invalid_org_rejected_locally_on_list(self) -> None: + with pytest.raises(ValueError): + list(self.service.list("")) + + def test_invalid_org_rejected_locally_on_update(self) -> None: + with pytest.raises(ValueError): + self.service.update( + "", + OrgTokenTTLPolicyUpdateOptions(team=3_600_000), + ) + + def test_empty_update_raises_before_request(self) -> None: + # No HTTP call should happen — the typed error fires at + # payload-build time. + self.transport.request.return_value = _resp({"data": []}) + with pytest.raises(RequiredFieldMissing): + self.service.update("my-org", OrgTokenTTLPolicyUpdateOptions()) + assert self.transport.request.call_count == 0