From 227980a748d81787f6bed13ff1abb3e8f2d658dd Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Mon, 5 Oct 2026 17:02:07 -0400 Subject: [PATCH 01/23] docs(gateway-access): spec gateway access management Specify a Manage access capability so gateway admins can grant, change, and revoke other users' access to a gateway, projected into per-gateway Keycloak client roles. - New specs: platform/gateway-access-management.spec.md (access API, roles, Keycloak directory search, creator protection, hsctl parity) and web-console/gateway-access-management.spec.md (the Manage access tab). - Add gateway:admin role (granted administrator); reserve gateway:owner for the immutable creator. Both map to openshell-admin + openshell-user; gateway:viewer maps to openshell-user. - Align the Keycloak OIDC Role Bridge to the implemented union/reconcile-to-union behavior (rbac-enforcement, openshell-gateway-keycloak). - Update data-model (endpoints + hsctl commands), user_flows, and gateway visibility references to include gateway:admin. The directory search endpoint serves the console Add users picker only; it is a documented exception to the hsctl 1-for-1 CLI mirror. Co-Authored-By: Claude Opus 4.8 --- specs/platform/data-model.spec.md | 9 +- .../gateway-access-management.spec.md | 395 ++++++++++++++++++ .../openshell-gateway-console.spec.md | 2 +- .../openshell-gateway-keycloak.spec.md | 99 +++-- specs/security/rbac-enforcement.spec.md | 83 ++-- specs/web-console/architecture.spec.md | 2 +- .../gateway-access-management.spec.md | 197 +++++++++ specs/web-console/user_flows.md | 44 +- 8 files changed, 737 insertions(+), 94 deletions(-) create mode 100644 specs/platform/gateway-access-management.spec.md create mode 100644 specs/web-console/gateway-access-management.spec.md diff --git a/specs/platform/data-model.spec.md b/specs/platform/data-model.spec.md index 951ceee22..fd08446aa 100644 --- a/specs/platform/data-model.spec.md +++ b/specs/platform/data-model.spec.md @@ -7,7 +7,7 @@ The HyperShell API server provides a control plane for deploying and managing distributed API gateways across multiple Kubernetes clusters and cloud providers. -Gateways, clusters, releases, and networks are **top-level resources**. An earlier model included a top-level "Sector" (later renamed "Fleet") organizational unit that grouped these resources via a `fleet_id`; that layer has been removed. There is no sectorization: all gateways belong to the same platform, and tenancy is enforced by RBAC (platform-level `gateway:creator`/`platform:admin` and per-gateway `gateway:owner`/`gateway:viewer`), not by a fleet grouping. See [`security/rbac-enforcement.spec.md`](../security/rbac-enforcement.spec.md). +Gateways, clusters, releases, and networks are **top-level resources**. An earlier model included a top-level "Sector" (later renamed "Fleet") organizational unit that grouped these resources via a `fleet_id`; that layer has been removed. There is no sectorization: all gateways belong to the same platform, and tenancy is enforced by RBAC (platform-level `gateway:creator`/`platform:admin` and per-gateway `gateway:owner` (the creator), `gateway:admin` (granted administrators), and `gateway:viewer`), not by a fleet grouping. See [`security/rbac-enforcement.spec.md`](../security/rbac-enforcement.spec.md) and [`gateway-access-management.spec.md`](gateway-access-management.spec.md). Current model: @@ -414,6 +414,13 @@ The `hsctl` CLI mirrors the REST API 1-for-1. Every REST operation has a corresp | `GET /api/hypershell/v1/role_bindings/{id}` | `hsctl get roleBinding ` | ✅ implemented | | `POST /api/hypershell/v1/role_bindings` | `hsctl create roleBinding --role-id --scope [--user-id ]` | ✅ implemented | | `DELETE /api/hypershell/v1/role_bindings/{id}` | `hsctl delete roleBinding ` | ✅ implemented | +| `GET /api/hypershell/v1/gateways/{gateway_id}/access` | `hsctl list gatewayAccess --gateway-id [--role ] [--search ]` | planned (see [`gateway-access-management.spec.md`](gateway-access-management.spec.md) GAM-03) | +| `POST /api/hypershell/v1/gateways/{gateway_id}/access` | `hsctl create gatewayAccess --gateway-id --user --role ` | planned (GAM-04) | +| `PATCH /api/hypershell/v1/gateways/{gateway_id}/access/{user_id}` | `hsctl update gatewayAccess --gateway-id --role ` | planned (GAM-05) | +| `DELETE /api/hypershell/v1/gateways/{gateway_id}/access/{user_id}` | `hsctl delete gatewayAccess --gateway-id ` | planned (GAM-06) | +| `GET /api/hypershell/v1/gateways/{gateway_id}/access/directory` | -- (web console only) | planned Keycloak realm user search (GAM-09); serves the "Add users" picker, no CLI command | + +The gateway access facade is a gateway-scoped, enriched projection over the `role_bindings` resource above: `admin` grants write `gateway:admin` bindings, `user` grants write `gateway:viewer` bindings, and the creator's immutable `gateway:owner` binding cannot be granted, changed, or revoked. Granting access to a Keycloak realm user with no existing `User` record pre-provisions one (upsert keyed on `username`); the public users API remains read-only (see [`registered-users.spec.md`](registered-users.spec.md)). #### Users diff --git a/specs/platform/gateway-access-management.spec.md b/specs/platform/gateway-access-management.spec.md new file mode 100644 index 000000000..a7947ac0b --- /dev/null +++ b/specs/platform/gateway-access-management.spec.md @@ -0,0 +1,395 @@ +# Gateway Access Management Specification + +**Date:** 2026-10-05 +**Status:** Draft +**Applies to:** `components/api-server` roleBindings/roles/gateways/users plugins and RBAC middleware, `components/control-plane` Keycloak client and RoleBinding reconciler, `components/sdk-typescript`, `components/web-console` BFF +**Parent:** `security/rbac-enforcement.spec.md` -- scope-aware RBAC model and RoleBinding storage +**Related:** `openshell-gateway-keycloak.spec.md` -- per-gateway Keycloak clients and the OIDC Role Bridge; `openshell-gateway-service-accounts.spec.md` -- gateway-nested sub-resource and role capping; `registered-users.spec.md` -- registered User records and the read-only users inventory; `data-model.spec.md` -- Gateway, Role, RoleBinding, and User kinds; `web-console/gateway-access-management.spec.md` -- the Manage access console surface + +--- + +## Purpose + +This specification defines **gateway access management**: how a gateway's administrators grant, change, and revoke other users' access to a single gateway, and how those grants are projected into per-gateway Keycloak client roles so the granted users receive the same gateway access through the `openshell` CLI as they do in the management plane. + +Access is already modeled as per-gateway `RoleBinding` records and already projected into Keycloak by the control plane's OIDC Role Bridge (`openshell-gateway-keycloak.spec.md`). This specification: + +1. Adds a third per-gateway role, `gateway:admin`, so a **granted administrator** is distinct from the **creator** (`gateway:owner`). +2. Defines a gateway-scoped **access facade** (`/api/hypershell/v1/gateways/{gateway_id}/access`) that presents enriched access grants, atomic role changes, and creator protection in application language, over the existing RoleBinding storage and watch events. +3. Defines a **Keycloak directory search** so administrators can grant access to any user in the Keycloak realm, including users who have never signed in to HyperShell, pre-provisioning a HyperShell `User` record on grant. +4. Aligns the Keycloak Role Bridge documentation with the implemented behavior (owner and admin both receive `openshell-admin` and `openshell-user`). + +### Role vocabulary + +The management plane distinguishes three per-gateway roles. The gateway (and therefore the console and the `openshell` CLI) distinguishes only two tiers, **Admin** and **User**, which correspond to the Keycloak client roles `openshell-admin` and `openshell-user`. + +| Management-plane role | Who holds it | Console label | Keycloak client roles | +| --- | --- | --- | --- | +| `gateway:owner` | The creator; exactly one per gateway; immutable | Admin | `openshell-admin`, `openshell-user` | +| `gateway:admin` | A granted administrator; zero or more; removable | Admin | `openshell-admin`, `openshell-user` | +| `gateway:viewer` | A granted standard user; zero or more; removable | User | `openshell-user` | + +`gateway:owner` and `gateway:admin` are indistinguishable to the gateway: both are gateway administrators. The distinction exists only in the management plane, to protect the creator (see GAM-07) and to keep ownership singular. + +## Non-Goals + +- Identity-provider **group** grants. Access is granted to individual users only. Group-based access remains unresolved (`web-console/user_flows.md`). +- **Ownership transfer.** The `gateway:owner` binding is immutable for the life of the gateway; reassigning the creator is out of scope. +- Changing how `platform:admin`, `gateway:creator`, or Keycloak realm roles are assigned. Those remain Keycloak-sourced (`security/rbac-enforcement.spec.md`). +- Using Keycloak Organizations (the Keycloak feature). "Keycloak organization" in requirements means the configured realm's user directory; tenancy stays RBAC-based. + +## Requirements + +### Requirement: GAM-01 -- Granted Administrator Role + +The platform SHALL define a per-gateway built-in role `gateway:admin` representing a granted gateway administrator. + +`gateway:admin` SHALL confer, on its bound gateway, the same capabilities as `gateway:owner` EXCEPT deleting the gateway: + +- Read and update the gateway (including rename). +- Manage access: grant, change, and revoke `gateway:admin` and `gateway:viewer` bindings (GAM-08). +- Create and manage all OpenShellGatewayServiceAccounts on the gateway, selecting `openshell-user` or `openshell-admin` (GAM-12). + +`gateway:admin` SHALL NOT authorize deleting the gateway. Deleting a gateway SHALL require `gateway:owner` or `platform:admin`. + +`gateway:admin` SHALL be stored and granted exactly like other per-gateway bindings: a `RoleBinding` with `scope=gateway` and a `gateway_id`. It SHALL NOT be a JWT-synced role and SHALL NOT be assignable via Keycloak realm roles. + +A user holding `gateway:admin` on a gateway SHALL see that gateway in list and get results (gateway visibility follows any per-gateway binding). + +#### Scenario: Granted admin can manage the gateway but not delete it + +- GIVEN user B has `gateway:admin` on gw-1 +- WHEN user B calls `PATCH /api/hypershell/v1/gateways/gw-1` +- THEN the request SHALL be authorized +- WHEN user B calls `DELETE /api/hypershell/v1/gateways/gw-1` +- THEN the response SHALL be `403 Forbidden` + +#### Scenario: Granted admin sees the gateway + +- GIVEN user B has `gateway:admin` on gw-1 and no other bindings +- WHEN user B calls `GET /api/hypershell/v1/gateways` +- THEN the response SHALL include gw-1 + +--- + +### Requirement: GAM-02 -- Keycloak Role Bridge Mapping + +The control plane SHALL map per-gateway HyperShell roles to per-gateway Keycloak client roles as follows: + +| HyperShell role | Keycloak client roles | +| --- | --- | +| `gateway:owner` | `openshell-admin`, `openshell-user` | +| `gateway:admin` | `openshell-admin`, `openshell-user` | +| `gateway:viewer` | `openshell-user` | + +A gateway administrator (owner or admin) SHALL therefore hold **both** `openshell-admin` and `openshell-user` on that gateway's Keycloak client; a standard user SHALL hold only `openshell-user`. This supersedes any prior "highest-privilege single role" description. + +On every RoleBinding event (created, updated, deleted) for a gateway-scoped binding, the control plane SHALL reconcile the bound user's Keycloak client roles on that gateway to the **exact union** of client roles implied by the user's current surviving bindings on that gateway: it SHALL assign any missing client role and remove any client role no longer backed by a surviving binding. This makes role changes and demotions converge correctly and makes repeated events idempotent. + +#### Scenario: Admin grant assigns both client roles + +- GIVEN gw-1 has a provisioned Keycloak client +- WHEN a `gateway:admin` binding for user B on gw-1 is created +- THEN the control plane SHALL assign `openshell-admin` and `openshell-user` to user B on gw-1's Keycloak client + +#### Scenario: Demotion strips the admin client role + +- GIVEN user B holds `gateway:admin` on gw-1 (Keycloak `openshell-admin` + `openshell-user`) +- WHEN user B's access is changed to `gateway:viewer` +- THEN the control plane SHALL remove `openshell-admin` from user B on gw-1's Keycloak client +- AND `openshell-user` SHALL remain + +#### Scenario: Overlapping bindings keep the union + +- GIVEN user B holds both `gateway:viewer` and `gateway:admin` on gw-1 +- WHEN the `gateway:viewer` binding is deleted +- THEN `openshell-user` SHALL remain assigned to user B on gw-1 (still backed by the `gateway:admin` binding) +- AND `openshell-admin` SHALL remain assigned + +--- + +### Requirement: GAM-03 -- Gateway Access List + +The API server SHALL expose `GET /api/hypershell/v1/gateways/{gateway_id}/access`, returning the list of access grants on the gateway as an enriched, paginated collection. + +Each grant item SHALL expose: + +| Field | Type | Notes | +| --- | --- | --- | +| `role_binding_id` | string | The backing RoleBinding id; used by change/revoke | +| `user_id` | string | HyperShell User id | +| `username` | string | `User.username` (= Keycloak `preferred_username`); the console "User ID" column | +| `name` | string | `User.name` display name, when present; the console "User name" column | +| `email` | string | Optional | +| `role` | string | `admin` or `user` (the console tier); `admin` covers both `gateway:owner` and `gateway:admin` | +| `is_creator` | boolean | `true` for the single `gateway:owner` grant | +| `granted_at` | date-time | Binding creation time | + +The response SHALL be assembled server-side with the user fields already joined; it SHALL NOT require the caller to perform one lookup per row. The endpoint SHALL support `search` (case-insensitive substring over `username` and `name`), a `role` filter (`admin` or `user`), pagination, and ordering, following the shared list contract. Search terms SHALL be treated as literals (wildcard/escape semantics handled server-side). + +Any caller with a binding on the gateway (owner, admin, or viewer) or `platform:admin` SHALL be authorized to read the access list. Callers with no access to the gateway SHALL receive `404` (existence not disclosed, per `security/rbac-enforcement.spec.md`). + +#### Scenario: Owner lists access + +- GIVEN gw-1 has a creator (owner), one `gateway:admin`, and two `gateway:viewer` grants +- WHEN the owner calls `GET /api/hypershell/v1/gateways/gw-1/access` +- THEN the response SHALL contain four items with `username`, `name`, `role`, and `is_creator` populated +- AND exactly one item SHALL have `is_creator: true` and `role: admin` + +#### Scenario: Filter by role and search by name + +- WHEN a caller requests the access list with `role=user` and `search=ali` +- THEN only `user`-tier grants whose username or name contains `ali` SHALL be returned + +#### Scenario: Non-member cannot read the access list + +- GIVEN user C has no binding on gw-1 and is not `platform:admin` +- WHEN user C calls `GET /api/hypershell/v1/gateways/gw-1/access` +- THEN the response SHALL be `404` + +--- + +### Requirement: GAM-04 -- Grant Access + +The API server SHALL expose `POST /api/hypershell/v1/gateways/{gateway_id}/access` to grant a user `admin` or `user` access to the gateway. + +The request SHALL identify the target user by a directory identity (`username`, or an equivalent Keycloak subject reference returned by GAM-09) and a `role` of `admin` or `user`. Granting `admin` SHALL create a `gateway:admin` binding; granting `user` SHALL create a `gateway:viewer` binding. The endpoint SHALL NOT create `gateway:owner` bindings (GAM-07). + +When the target identity has no existing HyperShell `User` record, the API server SHALL **pre-provision** one from the Keycloak directory record (upsert keyed on `username`, populating `username`, `email`, and `name`) before creating the binding, so the binding references a real `user_id` and the Role Bridge can resolve the Keycloak user by username. Pre-provisioning SHALL use the same upsert semantics as JWT auto-provisioning (`security/rbac-enforcement.spec.md`) and SHALL NOT expand the public users API, which remains read-only (`registered-users.spec.md`). + +Granting SHALL be idempotent with respect to the effective role (GAM-10). On success the endpoint SHALL return the resulting grant item (GAM-03 shape). + +#### Scenario: Grant admin to a previously registered user + +- GIVEN user B has a HyperShell User record and no binding on gw-1 +- WHEN an owner of gw-1 grants B the `admin` role +- THEN a `gateway:admin` binding SHALL be created for B on gw-1 +- AND the Role Bridge SHALL assign `openshell-admin` and `openshell-user` to B on gw-1 + +#### Scenario: Grant access to a user who has never signed in + +- GIVEN directory user `dana` exists in the Keycloak realm with no HyperShell User record +- WHEN an owner of gw-1 grants `dana` the `user` role +- THEN the API server SHALL pre-provision a User record for `dana` from the directory +- AND SHALL create a `gateway:viewer` binding for `dana` on gw-1 +- AND the Role Bridge SHALL assign `openshell-user` to `dana` on gw-1 + +#### Scenario: Cannot grant the owner role + +- WHEN a caller posts an access grant with a role that is not `admin` or `user` (for example attempting to grant owner) +- THEN the request SHALL be rejected with `400` + +--- + +### Requirement: GAM-05 -- Change Role + +The API server SHALL expose `PATCH /api/hypershell/v1/gateways/{gateway_id}/access/{user_id}` to change a user's access between `admin` and `user`. + +The change SHALL be atomic: the user SHALL NOT be left with zero gateway access or with conflicting bindings at any observable point. The server SHALL converge the user's gateway bindings to exactly the requested tier (a single `gateway:admin` or `gateway:viewer` binding) within one transaction, emitting watch events such that the Role Bridge reconciles to the correct Keycloak client-role union (GAM-02). + +Changing the creator's role SHALL be rejected (GAM-07). Changing to a role the user already holds SHALL be a no-op success (GAM-10). + +#### Scenario: Promote a user to admin + +- GIVEN user B holds `gateway:viewer` on gw-1 +- WHEN an admin of gw-1 PATCHes B's access to `admin` +- THEN B SHALL hold exactly `gateway:admin` on gw-1 afterward +- AND the Role Bridge SHALL add `openshell-admin` for B on gw-1 + +#### Scenario: Role change never drops access mid-flight + +- GIVEN user B holds `gateway:admin` on gw-1 +- WHEN B's access is changed to `user` +- THEN at no observable point SHALL B have zero bindings on gw-1 +- AND B SHALL end with exactly `gateway:viewer` + +--- + +### Requirement: GAM-06 -- Revoke Access + +The API server SHALL expose `DELETE /api/hypershell/v1/gateways/{gateway_id}/access/{user_id}` to revoke a user's access to the gateway. + +Revocation SHALL remove the user's `gateway:admin` and/or `gateway:viewer` bindings on the gateway and SHALL cause the Role Bridge to remove the corresponding Keycloak client roles for that user on that gateway's client. Revoking the creator SHALL be rejected (GAM-07). + +#### Scenario: Revoke a granted admin + +- GIVEN user B holds `gateway:admin` on gw-1 +- WHEN an owner of gw-1 revokes B's access +- THEN B SHALL hold no binding on gw-1 +- AND the Role Bridge SHALL remove `openshell-admin` and `openshell-user` for B on gw-1's Keycloak client + +#### Scenario: A granted admin may revoke their own access + +- GIVEN user B holds `gateway:admin` on gw-1 and is not the creator +- WHEN user B revokes their own access +- THEN the request SHALL succeed and B SHALL lose access to gw-1 + +--- + +### Requirement: GAM-07 -- Creator Protection + +The `gateway:owner` binding (the creator, auto-created at gateway creation per `security/rbac-enforcement.spec.md`) SHALL be immutable for the life of the gateway. + +The access facade SHALL: + +- Reject creating a second `gateway:owner` binding (`400`); the console grants `admin` as `gateway:admin`, never `gateway:owner`. +- Reject changing the creator's role (`PATCH` targeting the creator) with `409 Conflict`. +- Reject revoking the creator (`DELETE` targeting the creator) with `409 Conflict`. + +These rejections SHALL apply regardless of the caller, including the creator acting on themselves and any other administrator. As a consequence, a creator cannot remove or demote themselves, and a gateway always retains exactly one owner until it is deleted. + +#### Scenario: Creator cannot demote themselves + +- GIVEN user A is the creator (owner) of gw-1 +- WHEN user A PATCHes their own access to `user` +- THEN the response SHALL be `409 Conflict` +- AND user A SHALL remain `gateway:owner` + +#### Scenario: Creator cannot be removed by another admin + +- GIVEN user A is the creator of gw-1 and user B has `gateway:admin` on gw-1 +- WHEN user B revokes user A's access +- THEN the response SHALL be `409 Conflict` +- AND user A SHALL remain `gateway:owner` + +--- + +### Requirement: GAM-08 -- Access Management Authorization + +Granting, changing, and revoking access (GAM-04, GAM-05, GAM-06) and searching the directory (GAM-09) SHALL require `gateway:owner` or `gateway:admin` on the target gateway. + +`gateway:viewer` callers SHALL NOT manage access or search the directory. `platform:admin` SHALL NOT grant, change, or revoke access unless it also holds `gateway:owner` or `gateway:admin` on that gateway (consistent with `platform:admin` being view/delete-only for RBAC grants in `security/rbac-enforcement.spec.md`). Unauthorized management attempts SHALL return `403`; callers with no access to the gateway SHALL receive `404`. + +#### Scenario: Viewer cannot grant access + +- GIVEN user C holds `gateway:viewer` on gw-1 +- WHEN user C posts an access grant on gw-1 +- THEN the response SHALL be `403` + +#### Scenario: Granted admin can grant access + +- GIVEN user B holds `gateway:admin` on gw-1 +- WHEN user B grants user D the `user` role on gw-1 +- THEN the grant SHALL be created + +--- + +### Requirement: GAM-09 -- Keycloak Directory Search + +The API server SHALL expose `GET /api/hypershell/v1/gateways/{gateway_id}/access/directory?search=` returning candidate users from the configured Keycloak realm for the "Add users" picker. + +Each candidate SHALL expose `username`, `name`, and `email`, and SHALL be usable as the target identity of a grant (GAM-04). Candidates SHALL include realm users who have never signed in to HyperShell (the search is against the Keycloak directory, not the HyperShell users inventory). + +Because the API server SHALL NOT read the `hypershell-keycloak-admin` Secret (`openshell-gateway-keycloak.spec.md`), the API server SHALL obtain directory results from the control plane over the existing in-cluster gRPC path used for Keycloak-backed provisioning. The control plane SHALL query the Keycloak Admin REST API realm user search and return candidates. Results SHALL be bounded (paginated/capped) and the search SHALL require a non-trivial query term to avoid enumerating the entire realm in one call. + +Authorization SHALL follow GAM-08 (owner or admin on the gateway). + +#### Scenario: Directory search returns realm users + +- GIVEN the realm contains users `dana` and `dale`, neither registered in HyperShell +- WHEN an admin of gw-1 calls `GET /api/hypershell/v1/gateways/gw-1/access/directory?search=da` +- THEN the response SHALL include candidates for `dana` and `dale` with `username` and `name` + +#### Scenario: Directory search requires access management authorization + +- GIVEN user C holds only `gateway:viewer` on gw-1 +- WHEN user C calls the directory search for gw-1 +- THEN the response SHALL be `403` + +--- + +### Requirement: GAM-10 -- One Effective Role Per User Per Gateway + +A user SHALL have at most one effective access tier on a gateway. The access facade SHALL NOT create duplicate or conflicting bindings for the same user on the same gateway. + +Granting a role the user already holds SHALL be an idempotent no-op success. Granting a different tier than the user currently holds SHALL converge to the new tier (equivalent to a change, GAM-05), not accumulate bindings. The control plane Role Bridge SHALL remain correct under any surviving-binding union regardless (GAM-02). + +#### Scenario: Re-granting the same role is a no-op + +- GIVEN user B holds `gateway:viewer` on gw-1 +- WHEN an admin grants B the `user` role again +- THEN the request SHALL succeed +- AND B SHALL still hold exactly one `gateway:viewer` binding on gw-1 + +#### Scenario: Granting a higher tier converges, not accumulates + +- GIVEN user B holds `gateway:viewer` on gw-1 +- WHEN an admin grants B the `admin` role +- THEN B SHALL hold exactly `gateway:admin` on gw-1 and no `gateway:viewer` binding + +--- + +### Requirement: GAM-11 -- Service Account Role Capping for Admins + +OpenShellGatewayServiceAccount creation (`openshell-gateway-service-accounts.spec.md`) caps the selectable OpenShell role at the creator's gateway tier. A `gateway:admin` caller SHALL be capped identically to `gateway:owner`: it MAY select `openshell-user` or `openshell-admin`. A `gateway:viewer` caller MAY select only `openshell-user`. + +#### Scenario: Granted admin creates an admin service account + +- GIVEN user B holds `gateway:admin` on gw-1 +- WHEN user B creates an OpenShellGatewayServiceAccount on gw-1 with role `openshell-admin` +- THEN the request SHALL be authorized + +--- + +### Requirement: GAM-12 -- Role Migration + +Introducing `gateway:admin` SHALL be an additive, idempotent migration. The `gateway:admin` role SHALL be seeded with its permission set (gateway read/update, role_bindings create/read/delete/list, service_accounts manage) alongside the existing built-in roles. Existing `gateway:owner`, `gateway:viewer`, `gateway:creator`, and `platform:admin` bindings SHALL be unaffected, and re-running the migration SHALL be safe. + +No historical `gateway:owner` binding SHALL be rewritten to `gateway:admin`; existing gateways keep their single owner, and additional administrators are added going forward as `gateway:admin`. + +#### Scenario: Migration seeds the role idempotently + +- GIVEN a database already migrated with the existing built-in roles +- WHEN the role seeding migration runs (including on a second run) +- THEN a single `gateway:admin` role SHALL exist with its permissions +- AND no existing binding SHALL be modified + +--- + +### Requirement: GAM-13 -- hsctl CLI Parity + +The `hsctl` CLI mirrors the REST API one-for-one (`data-model.spec.md`), as it already does for OpenShellGatewayServiceAccounts. The gateway access operations SHALL therefore have corresponding commands, following the gateway-nested `--gateway-id` pattern established by service accounts: + +| REST operation | `hsctl` command | +| --- | --- | +| GAM-03 list access | `hsctl list gatewayAccess --gateway-id [--role ] [--search ]` | +| GAM-04 grant access | `hsctl create gatewayAccess --gateway-id --user --role ` | +| GAM-05 change role | `hsctl update gatewayAccess --gateway-id --role ` | +| GAM-06 revoke access | `hsctl delete gatewayAccess --gateway-id ` | + +The directory search (GAM-09) is a deliberate exception to the CLI mirror: it serves the web console "Add users" picker and SHALL NOT have an `hsctl` command. CLI callers already supply the username to `create gatewayAccess --user `. + +`--role` SHALL accept `admin` or `user` (mapping to `gateway:admin` and `gateway:viewer`). The commands SHALL use the generated SDK and SHALL surface the same authorization and creator-protection outcomes as the REST API -- a `403` (unauthorized management) or `409` (creator mutation) SHALL produce a non-zero exit with a clear message rather than a silent success. These commands manage access only; they SHALL NOT create or modify the creator's `gateway:owner` binding (GAM-07). + +#### Scenario: Grant then revoke access via hsctl + +- GIVEN the caller administers gw-1 +- WHEN the caller runs `hsctl create gatewayAccess --gateway-id gw-1 --user dana --role user` +- THEN dana SHALL gain user access to gw-1 +- WHEN the caller runs `hsctl delete gatewayAccess --gateway-id gw-1` +- THEN dana's access SHALL be revoked + +#### Scenario: CLI surfaces creator protection + +- GIVEN user A is the creator of gw-1 +- WHEN the caller runs `hsctl delete gatewayAccess --gateway-id gw-1` +- THEN the command SHALL exit non-zero with a message that the gateway creator cannot be removed (`409`) + +--- + +### Requirement: GAM-14 -- Verification + +The API server SHALL include integration tests covering: enriched access list with search and role filter; grant to a registered user and to a never-signed-in directory user (pre-provisioning); atomic role change (promote and demote) with no zero-access window; revoke; creator-protection rejections (`409`) for demote and revoke, including by another admin; management authorization (viewer `403`, admin allowed, non-member `404`); directory-search authorization; and idempotent re-grant. + +The control plane SHALL include tests covering the `gateway:admin` bridge mapping and reconcile-to-union on create, update, and delete (including demotion stripping `openshell-admin` while retaining `openshell-user`). + +The `hsctl` CLI SHALL include tests covering the access commands (GAM-13), including a creator-protection failure surfacing as a non-zero exit. + +`make generate` SHALL regenerate the Go and TypeScript SDK clients for the new endpoints. + +#### Scenario: CI exercises creator protection and demotion + +- GIVEN the integration and control-plane test suites run +- WHEN access-management and Role Bridge tests execute +- THEN they SHALL assert `409` on creator demote/revoke +- AND assert that demoting an admin removes `openshell-admin` while keeping `openshell-user` diff --git a/specs/platform/openshell-gateway-console.spec.md b/specs/platform/openshell-gateway-console.spec.md index 11a508454..f770eefd0 100644 --- a/specs/platform/openshell-gateway-console.spec.md +++ b/specs/platform/openshell-gateway-console.spec.md @@ -12,7 +12,7 @@ The Gateway Console is the OpenShell dashboard for one gateway. The control plane deploys a console with each gateway that has a route. The console runs in the gateway namespace (`openshell-`). The console connects to the gateway through the in-cluster Service. A user opens the console in a browser at a per-gateway hostname. -An oauth2-proxy sidecar authenticates the browser against a dedicated Keycloak client. The access token carries the gateway audience (`aud = {name}-{id}`) and the user roles (`hypershell.roles`). The dashboard sends this token to the gateway. The gateway validates the token with the rules that it applies to the CLI. The OIDC Role Bridge controls access. A user operates the console only where a `gateway:owner` or `gateway:viewer` RoleBinding exists. +An oauth2-proxy sidecar authenticates the browser against a dedicated Keycloak client. The access token carries the gateway audience (`aud = {name}-{id}`) and the user roles (`hypershell.roles`). The dashboard sends this token to the gateway. The gateway validates the token with the rules that it applies to the CLI. The OIDC Role Bridge controls access. A user operates the console only where a `gateway:owner`, `gateway:admin`, or `gateway:viewer` RoleBinding exists. This spec covers Option 1: one oauth2-proxy for each gateway, real tokens with the correct audience, no token exchange, and no shared credential across gateways. diff --git a/specs/platform/openshell-gateway-keycloak.spec.md b/specs/platform/openshell-gateway-keycloak.spec.md index 5c16aa634..bb85ac45e 100644 --- a/specs/platform/openshell-gateway-keycloak.spec.md +++ b/specs/platform/openshell-gateway-keycloak.spec.md @@ -15,7 +15,7 @@ This specification defines automated per-gateway Keycloak OIDC client provisioni 1. **Client lifecycle** -- When a gateway is created, the control plane provisions a dedicated OIDC client in Keycloak with client-scoped roles and protocol mappers, and populates the gateway's OIDC configuration. When a gateway is deleted, the control plane deletes the Keycloak client. This lifecycle is tied to Gateway ADDED/DELETED events. -2. **Role assignment lifecycle** -- When a user's per-gateway RoleBinding changes (created or deleted), the control plane propagates that change to the corresponding Keycloak client role on that gateway. This implements the Gateway OIDC Role Bridge defined in [`rbac-enforcement.spec.md`](../security/rbac-enforcement.spec.md). The mapping is per-gateway: a `gateway:owner` binding on gw-1 results in an `openshell-admin` Keycloak client role assignment on gw-1. +2. **Role assignment lifecycle** -- When a user's per-gateway RoleBinding changes (created, updated, or deleted), the control plane reconciles that user's Keycloak client roles on that gateway to the union implied by their surviving bindings. This implements the Gateway OIDC Role Bridge defined in [`rbac-enforcement.spec.md`](../security/rbac-enforcement.spec.md). The mapping is per-gateway: a `gateway:owner` or `gateway:admin` binding on gw-1 results in `openshell-admin` and `openshell-user` client role assignments on gw-1; a `gateway:viewer` binding results in `openshell-user`. This establishes per-gateway authentication isolation: each gateway has its own audience, roles, and token claims. Visibility and access control are RBAC concerns defined in the RBAC spec -- this spec defines how RBAC decisions are projected into Keycloak role assignments so that users who connect to gateways directly via the `openshell` CLI receive the same access level as in the management plane. @@ -63,19 +63,20 @@ OIDC Role Bridge (RoleBinding ADDED/DELETED events): | Body: { role_id, scope: "gateway", user_id, gateway_id } v API Server - | 1. Authorizes (caller must be gateway:owner on the target gateway) + | 1. Authorizes (caller must be gateway:owner or gateway:admin on the target gateway) | 2. Persists RoleBinding | 3. Emits gRPC watch event v Control Plane - | 1. Receives RoleBinding ADDED/DELETED event - | 2. Maps the HyperShell role to a Keycloak client role: - | - gateway:owner → openshell-admin + | 1. Receives RoleBinding ADDED/UPDATED/DELETED event + | 2. Maps the HyperShell role to Keycloak client roles: + | - gateway:owner → openshell-admin, openshell-user + | - gateway:admin → openshell-admin, openshell-user | - gateway:viewer → openshell-user | 3. Resolves the gateway from the binding's gateway_id - | 4. On the gateway's Keycloak client: - | - ADDED: assigns the Keycloak client role to the user - | - DELETED: removes the Keycloak client role from the user + | 4. On the gateway's Keycloak client, reconciles the user's client + | roles to the union of their surviving bindings (assign missing, + | remove no-longer-backed roles) v User can now obtain tokens with the assigned role for that gateway ``` @@ -136,23 +137,39 @@ This specification implements the **Gateway OIDC Role Bridge** defined in [`rbac ### Role Mapping -The control plane maps HyperShell RBAC roles to per-gateway Keycloak client roles: +The control plane maps HyperShell RBAC roles to per-gateway Keycloak client roles. A +gateway administrator (owner or admin) receives **both** client roles; a standard user +receives only `openshell-user`: -| HyperShell Role | Keycloak Client Role | Scope | +| HyperShell Role | Keycloak Client Roles | Scope | |---|---|---| -| `gateway:owner` | `openshell-admin` | The specific bound gateway | +| `gateway:owner` | `openshell-admin`, `openshell-user` | The specific bound gateway | +| `gateway:admin` | `openshell-admin`, `openshell-user` | The specific bound gateway | | `gateway:viewer` | `openshell-user` | The specific bound gateway | -`gateway:creator` is not mapped -- it grants the ability to create gateways but does not confer access to any specific gateway. The creator automatically receives a `gateway:owner` RoleBinding on creation (see [`rbac-enforcement.spec.md`](../security/rbac-enforcement.spec.md)), which provides the Keycloak role through the mapping above. +`gateway:owner` (the creator) and `gateway:admin` (a granted administrator) are +indistinguishable to the gateway; the owner/admin distinction is a management-plane +concern only (see `gateway-access-management.spec.md`). `gateway:creator` is not mapped -- +it grants the ability to create gateways but does not confer access to any specific +gateway. The creator automatically receives a `gateway:owner` RoleBinding on creation +(see [`rbac-enforcement.spec.md`](../security/rbac-enforcement.spec.md)), which provides +the Keycloak roles through the mapping above. ### Effective Role Resolution -When a user has multiple RoleBindings on the same gateway, the **highest-privilege** Keycloak client role wins. A user with both `gateway:viewer` (→ `openshell-user`) and `gateway:owner` (→ `openshell-admin`) on the same gateway SHALL have the `openshell-admin` client role. +A user's Keycloak client roles on a gateway SHALL be the **union** of the roles implied +by all of that user's surviving RoleBindings on that gateway. A user with both +`gateway:viewer` (→ `openshell-user`) and `gateway:admin` (→ `openshell-admin`, +`openshell-user`) on the same gateway SHALL hold both `openshell-admin` and +`openshell-user`. On every RoleBinding create, update, or delete, the control plane SHALL +reconcile the user's client roles on that gateway to this exact union -- assigning any +missing role and removing any role no longer backed by a surviving binding -- so that +promotions, demotions, and overlapping grants all converge correctly and idempotently. ### Lifecycle Interactions - **Gateway created** → the control plane provisions the Keycloak client and resolves existing RoleBindings for that gateway (initially the auto-provisioned `gateway:owner` for the creator), assigning the corresponding Keycloak client roles. -- **RoleBinding created/deleted for a gateway** → the control plane assigns or removes the Keycloak client role on that gateway's Keycloak client. +- **RoleBinding created/updated/deleted for a gateway** → the control plane reconciles the bound user's client roles on that gateway's Keycloak client to the union of their surviving bindings (see Effective Role Resolution), assigning missing roles and removing roles no longer backed by any binding. - **OpenShellGatewayServiceAccount lifecycle change** → the API records the desired state and the control plane creates, disables, or deletes the related service-account client. See `openshell-gateway-service-accounts.spec.md`. - **Gateway deleted** → Keycloak client deletion cascades all role assignments automatically (see Client Cleanup requirement). @@ -274,7 +291,7 @@ After creating the client, the creator's `gateway:owner` role is assigned via th - AND the API server auto-provisions a `gateway:owner` RoleBinding for user-a on `gw-new` - WHEN the RoleBindingReconciler receives the RoleBinding ADDED event - THEN it SHALL resolve the Keycloak client ID as `gw-new-xyz789` -- AND it SHALL assign `openshell-admin` to user-a on the `gw-new-xyz789` client +- AND it SHALL assign `openshell-admin` and `openshell-user` to user-a on the `gw-new-xyz789` client - NOTE: If the Keycloak client is not yet provisioned, the reconciler retries with exponential backoff (see Role Assignment Retry) #### Scenario: Duplicate client ID in Keycloak @@ -357,49 +374,48 @@ Maps the client's roles from `resource_access.{clientId}.roles` to a fixed `hype #### Scenario: Token contains correct claims after provisioning -- GIVEN user `user-a` has the `openshell-admin` role on client `my-gateway-2FhMpQzXBz` +- GIVEN user `user-a` has the `openshell-admin` and `openshell-user` roles on client `my-gateway-2FhMpQzXBz` (a gateway administrator) - WHEN `user-a` obtains a token using `client_id = my-gateway-2FhMpQzXBz` - THEN the access token SHALL contain `aud: "my-gateway-2FhMpQzXBz"` - AND the access token SHALL contain `sub: "user-a-sub-id"` -- AND the access token SHALL contain `hypershell.roles: ["openshell-admin"]` +- AND the access token SHALL contain `hypershell.roles: ["openshell-admin", "openshell-user"]` - AND the access token SHALL NOT contain roles from any other gateway's client --- ### Requirement: RBAC-Driven Keycloak Role Assignment (OIDC Role Bridge) -Keycloak client role assignments SHALL be driven by RoleBinding events, implementing the Gateway OIDC Role Bridge defined in [`rbac-enforcement.spec.md`](../security/rbac-enforcement.spec.md). The control plane SHALL assign or remove Keycloak client roles when RoleBindings are created or deleted, using the role mapping defined in the RBAC Role Bridge section above. +Keycloak client role assignments SHALL be driven by RoleBinding events, implementing the Gateway OIDC Role Bridge defined in [`rbac-enforcement.spec.md`](../security/rbac-enforcement.spec.md). On each RoleBinding created, updated, or deleted event, the control plane SHALL reconcile the bound user's Keycloak client roles on the referenced gateway to the **union** of client roles implied by the user's surviving per-gateway bindings, using the role mapping defined in the RBAC Role Bridge section above (`gateway:owner` and `gateway:admin` → `openshell-admin` + `openshell-user`; `gateway:viewer` → `openshell-user`). -All per-gateway RoleBindings (`gateway:owner`, `gateway:viewer`) have `scope=gateway` and reference a specific `gateway_id`. The `gateway:creator` role is global-scoped and sourced from Keycloak JWT claims -- it does not participate in the OIDC Role Bridge. +All per-gateway RoleBindings (`gateway:owner`, `gateway:admin`, `gateway:viewer`) have `scope=gateway` and reference a specific `gateway_id`. The `gateway:creator` role is global-scoped and sourced from Keycloak JWT claims -- it does not participate in the OIDC Role Bridge. -#### RoleBinding ADDED +#### RoleBinding ADDED or UPDATED -For each RoleBinding ADDED event with `scope=gateway`, the control plane SHALL: -1. Map the HyperShell role to a Keycloak client role (`openshell-admin` or `openshell-user`) +For each RoleBinding ADDED or UPDATED event with `scope=gateway`, the control plane SHALL: +1. Compute the desired union of Keycloak client roles from the user's surviving bindings on this gateway 2. Resolve the gateway from the binding's `gateway_id` and compute the Keycloak client ID (`{name}-{id}`) -3. Resolve the User's Keycloak identity (the `username` from the User record, which is populated from the JWT `preferred_username` claim at auto-provisioning time) +3. Resolve the User's Keycloak identity (the `username` from the User record, populated from the JWT `preferred_username` claim at auto-provisioning or pre-provisioning time) 4. Look up the user in Keycloak (`GET /admin/realms/{realm}/users?username={username}`) -5. On the gateway's Keycloak client: - - Retrieve the client role UUID - - Assign the role to the user (`POST /admin/realms/{realm}/users/{user-uuid}/role-mappings/clients/{client-uuid}`) +5. On the gateway's Keycloak client, assign any client role in the desired union not already mapped, and remove any client role mapped but not in the desired union (`POST`/`DELETE /admin/realms/{realm}/users/{user-uuid}/role-mappings/clients/{client-uuid}`) 6. If the Keycloak client does not yet exist (race with gateway provisioning), retry with exponential backoff (see Role Assignment Retry) #### RoleBinding DELETED -For each RoleBinding DELETED event with `scope=gateway`, the control plane SHALL: -1. Resolve the gateway from the binding's `gateway_id` -2. Check whether the user has any remaining RoleBindings on this gateway -3. If the user still has a binding that maps to the same or higher Keycloak role, take no action -4. If the user has a remaining binding that maps to a lower role, downgrade the Keycloak client role -5. If no remaining bindings exist, remove the Keycloak client role mapping (`DELETE /admin/realms/{realm}/users/{user-uuid}/role-mappings/clients/{client-uuid}`) +For each RoleBinding DELETED event with `scope=gateway`, the control plane SHALL recompute the desired union across the user's remaining bindings on this gateway and remove only the client roles no longer backed by any surviving binding. If no bindings remain, all of the user's client role mappings on that gateway SHALL be removed. -#### Scenario: Gateway owner receives admin role +#### Scenario: Gateway owner receives admin and user roles - GIVEN gateway `gw-alpha` (id=`abc123`) exists with a provisioned Keycloak client `gw-alpha-abc123` - AND a User `user-a` exists in both HyperShell and Keycloak - WHEN a RoleBinding is created: `role=gateway:owner`, `scope=gateway`, `gateway_id=abc123`, `user_id=user-a` -- THEN the control plane SHALL assign `openshell-admin` to `user-a` on the `gw-alpha-abc123` Keycloak client -- AND `user-a` SHALL be able to obtain tokens with `hypershell.roles: ["openshell-admin"]` for `gw-alpha-abc123` +- THEN the control plane SHALL assign `openshell-admin` and `openshell-user` to `user-a` on the `gw-alpha-abc123` Keycloak client +- AND `user-a` SHALL be able to obtain tokens with `hypershell.roles: ["openshell-admin", "openshell-user"]` for `gw-alpha-abc123` + +#### Scenario: Granted admin receives admin and user roles + +- GIVEN gateway `gw-alpha` (id=`abc123`) exists with a provisioned Keycloak client `gw-alpha-abc123` +- WHEN a RoleBinding is created: `role=gateway:admin`, `scope=gateway`, `gateway_id=abc123`, `user_id=user-c` +- THEN the control plane SHALL assign `openshell-admin` and `openshell-user` to `user-c` on the `gw-alpha-abc123` Keycloak client #### Scenario: Gateway viewer receives user role @@ -407,13 +423,12 @@ For each RoleBinding DELETED event with `scope=gateway`, the control plane SHALL - WHEN a RoleBinding is created: `role=gateway:viewer`, `scope=gateway`, `gateway_id=abc123`, `user_id=user-b` - THEN the control plane SHALL assign `openshell-user` to `user-b` on the `gw-alpha-abc123` Keycloak client only -#### Scenario: RoleBinding deletion with remaining coverage +#### Scenario: Demotion strips the admin role while keeping user -- GIVEN user-a has `gateway:owner` (→ `openshell-admin`) on gw-alpha -- AND user-a also has `gateway:viewer` (→ `openshell-user`) on gw-alpha -- WHEN the `gateway:owner` RoleBinding is deleted -- THEN the control plane SHALL downgrade user-a to `openshell-user` on gw-alpha -- AND user-a SHALL NOT lose Keycloak roles entirely (the viewer binding still covers them) +- GIVEN user-a has `gateway:admin` (→ `openshell-admin`, `openshell-user`) on gw-alpha +- WHEN user-a's access is changed to `gateway:viewer` +- THEN the control plane SHALL remove `openshell-admin` from user-a on gw-alpha +- AND `openshell-user` SHALL remain (backed by the viewer binding) #### Scenario: RoleBinding deletion with no remaining coverage @@ -480,7 +495,7 @@ The OIDC fields on the Gateway resource SHALL be read-only -- not settable or up ### Requirement: Gateway Visibility Scoping -Gateway visibility is defined by the RBAC spec's scope-aware list filtering. The API server SHALL return only gateways where the caller has a RoleBinding (`gateway:owner` or `gateway:viewer`). This is enforced at the API layer by the RBAC authorization middleware; the UI receives only the gateways the authenticated user has access to. +Gateway visibility is defined by the RBAC spec's scope-aware list filtering. The API server SHALL return only gateways where the caller has a RoleBinding (`gateway:owner`, `gateway:admin`, or `gateway:viewer`). This is enforced at the API layer by the RBAC authorization middleware; the UI receives only the gateways the authenticated user has access to. This keycloak spec does not define the visibility query mechanism -- that is the responsibility of [`rbac-enforcement.spec.md`](../security/rbac-enforcement.spec.md). This spec defines only the Keycloak projection: when a user has a per-gateway RoleBinding, they also have the corresponding Keycloak client role, enabling them to obtain valid tokens for that gateway. diff --git a/specs/security/rbac-enforcement.spec.md b/specs/security/rbac-enforcement.spec.md index 26416f899..457a7c547 100644 --- a/specs/security/rbac-enforcement.spec.md +++ b/specs/security/rbac-enforcement.spec.md @@ -28,8 +28,9 @@ stranding users before Keycloak is fully configured. It SHALL NOT be set in any production or staging overlay. Users gain gateway access by being assigned `gateway:creator` or `platform:admin` in -Keycloak, or by being granted a per-gateway binding (`gateway:owner`, `gateway:viewer`) -by an existing gateway owner. +Keycloak, or by being granted a per-gateway binding (`gateway:admin`, `gateway:viewer`) +by an existing gateway owner or admin. `gateway:owner` is the creator's binding and is +not granted; see `platform/gateway-access-management.spec.md`. --- @@ -75,8 +76,8 @@ Role { ### RoleBinding Binds a Role to a User at a given scope. For `gateway:creator`, scope is `global` with -no resource FK. For `gateway:owner` and `gateway:viewer`, scope is `gateway` with -`gateway_id` identifying the bound gateway. +no resource FK. For `gateway:owner`, `gateway:admin`, and `gateway:viewer`, scope is +`gateway` with `gateway_id` identifying the bound gateway. ``` RoleBinding { @@ -107,7 +108,8 @@ Role ||--o{ RoleBinding : "granted_by" |------|-------|--------|---------| | `platform:admin` | global | Keycloak JWT | Platform-wide administration; can view and delete any gateway | | `gateway:creator` | global | Keycloak JWT | Can create gateways; auto-becomes `gateway:owner` on creation | -| `gateway:owner` | per gateway | DB (app logic) | Full CRUD on one gateway; can grant `gateway:owner` and `gateway:viewer` to others | +| `gateway:owner` | per gateway | DB (app logic) | The creator; exactly one per gateway; immutable (cannot be granted, demoted, or revoked). Full CRUD including delete; can grant `gateway:admin` and `gateway:viewer` to others | +| `gateway:admin` | per gateway | DB (app logic) | Granted administrator; zero or more; removable. Same as owner except cannot delete the gateway; can grant `gateway:admin` and `gateway:viewer` to others | | `gateway:viewer` | per gateway | DB (app logic) | Read-only access to one gateway | | `managed-cluster-registrar` | global | Keycloak JWT (direct, no DB binding) | Allows a control-plane service account (every control plane, the hub's co-located one included) to call `POST /managed_clusters/registration`; checked live from JWT claim, not via `JWTSyncedRoles` or DB RoleBinding | @@ -116,25 +118,33 @@ Role ||--o{ RoleBinding : "granted_by" | Role | Gateways | Gateway CRUD | RBAC Grants | OpenShell Mapping | OpenShellGatewayServiceAccounts | ManagedCluster Registration | |------|----------|-------------|-------------|-------------------|-----------------|-----------------| | `platform:admin` | view all, delete any | view all + delete any | -- | -- | None without a gateway binding | View all records; create, update, or delete any record | -| `gateway:creator` | create + own gateways | full (as owner) | grant owner/viewer on own gateways | `openshell-admin` on own gateways | Through the resulting owner binding | View all records; no writes | -| `gateway:owner` | full (one gateway) | full | grant owner/viewer on that gateway | `openshell-admin` on that gateway | Select `openshell-user` or `openshell-admin`. Manage all OpenShellGatewayServiceAccounts on the gateway. | -- | +| `gateway:creator` | create + own gateways | full (as owner) | grant admin/viewer on own gateways | `openshell-admin` + `openshell-user` on own gateways | Through the resulting owner binding | View all records; no writes | +| `gateway:owner` | full (one gateway) | full incl. delete | grant admin/viewer on that gateway | `openshell-admin` + `openshell-user` on that gateway | Select `openshell-user` or `openshell-admin`. Manage all OpenShellGatewayServiceAccounts on the gateway. | -- | +| `gateway:admin` | full except delete (one gateway) | read + update (no delete) | grant admin/viewer on that gateway | `openshell-admin` + `openshell-user` on that gateway | Select `openshell-user` or `openshell-admin`. Manage all OpenShellGatewayServiceAccounts on the gateway. | -- | | `gateway:viewer` | read (one gateway) | read only | -- | `openshell-user` on that gateway | Select only `openshell-user`. Manage only their own OpenShellGatewayServiceAccounts. | -- | | `managed-cluster-registrar` | none (unless also granted `gateway:creator` via defaults) | none | none | none | none | `POST /registration` (register + heartbeat loop) -- enforced by JWT-direct check in `isAuthorized`, not a DB binding. Once registered, the caller may delete its own record (self-deregistration). | ### OpenShell Role Bridge -When a user accesses a gateway directly via the `openshell` CLI, the gateway's OIDC -configuration maps HyperShell roles to OpenShell roles: +When a user accesses a gateway directly via the `openshell` CLI, the control plane +projects HyperShell roles onto per-gateway Keycloak client roles. A gateway +administrator (owner or admin) receives both client roles; a standard user receives +only `openshell-user`: -| HyperShell Role | OpenShell Role | -|-----------------|----------------| -| `gateway:owner` | `openshell-admin` | +| HyperShell Role | OpenShell (Keycloak client) roles | +|-----------------|-----------------------------------| +| `gateway:owner` | `openshell-admin`, `openshell-user` | +| `gateway:admin` | `openshell-admin`, `openshell-user` | | `gateway:viewer` | `openshell-user` | +The control plane reconciles each user's client roles to the union implied by their +surviving bindings on every RoleBinding event; see +`platform/gateway-access-management.spec.md` and `platform/openshell-gateway-keycloak.spec.md`. + The `platform:admin` role provides visibility and lifecycle management through the HyperShell web console but does NOT grant OpenShell CLI access to gateways. Platform administrators who need to use the `openshell` CLI for a specific gateway must be -granted `gateway:owner` or `gateway:viewer` on that gateway. +granted `gateway:admin` or `gateway:viewer` on that gateway. --- @@ -214,7 +224,11 @@ needed to match the JWT claims. Any authenticated user with the `gateway:creator` role SHALL be able to create gateways. On successful gateway creation, the system SHALL automatically create a `gateway:owner` -RoleBinding for the authenticated user, scoped to the new gateway. +RoleBinding for the authenticated user, scoped to the new gateway. This owner binding +identifies the creator; it is the only `gateway:owner` binding on the gateway and is +immutable (it cannot be granted, demoted, or revoked -- see +`platform/gateway-access-management.spec.md` GAM-07). Additional administrators are +added as `gateway:admin`, not `gateway:owner`. This binding is created in the same database transaction as the gateway. @@ -262,33 +276,50 @@ on all gateways the user owns (has a `gateway:owner` binding for). ### Requirement: RoleBinding Grants -Gateway owners can grant `gateway:owner` or `gateway:viewer` to other users on gateways -they own. There is no hierarchy restriction -- owners can make more owners. +Gateway owners and gateway admins can grant `gateway:admin` or `gateway:viewer` to other +users on gateways they administer. There is no hierarchy restriction among administrators +-- owners and admins can make more admins. Neither owners nor admins can grant +`gateway:owner`: the owner binding is the creator's and is never granted (see Gateway +Creation Bootstrap and `platform/gateway-access-management.spec.md` GAM-07). The console +performs these grants through the gateway access facade (`GAM-04`/`GAM-05`/`GAM-06`), +which writes the same RoleBinding records. #### Scenario: Owner invites a viewer - GIVEN user A has `gateway:owner` on gw-1 -- WHEN user A calls `POST /api/hypershell/v1/role_bindings` with `role=gateway:viewer`, `gateway_id=gw-1`, `user_id=B` +- WHEN user A grants `role=gateway:viewer`, `gateway_id=gw-1`, `user_id=B` - THEN the binding is created - AND user B gains read-only access to gw-1 -#### Scenario: Owner invites a co-owner +#### Scenario: Owner invites a co-admin - GIVEN user A has `gateway:owner` on gw-1 -- WHEN user A calls `POST /api/hypershell/v1/role_bindings` with `role=gateway:owner`, `gateway_id=gw-1`, `user_id=B` +- WHEN user A grants `role=gateway:admin`, `gateway_id=gw-1`, `user_id=B` +- THEN the binding is created +- AND user B gains administrative access to gw-1 (everything except deleting the gateway) + +#### Scenario: Admin can grant access + +- GIVEN user B has `gateway:admin` on gw-1 +- WHEN user B grants `role=gateway:viewer`, `gateway_id=gw-1`, `user_id=D` - THEN the binding is created -- AND user B gains full access to gw-1 + +#### Scenario: Granting gateway:owner is rejected + +- GIVEN user A has `gateway:owner` on gw-1 +- WHEN user A attempts to grant `role=gateway:owner` on gw-1 +- THEN the request is rejected (the owner binding cannot be granted) #### Scenario: Viewer cannot grant - GIVEN user A has only `gateway:viewer` on gw-1 -- WHEN user A calls `POST /api/hypershell/v1/role_bindings` with any role on gw-1 +- WHEN user A attempts to grant any role on gw-1 - THEN the request returns 403 Forbidden -#### Scenario: Cannot grant on a gateway you don't own +#### Scenario: Cannot grant on a gateway you don't administer -- GIVEN user A has `gateway:owner` on gw-1 only -- WHEN user A calls `POST /api/hypershell/v1/role_bindings` with `gateway_id=gw-2` +- GIVEN user A administers gw-1 only +- WHEN user A attempts to grant a binding with `gateway_id=gw-2` - THEN the request returns 403 Forbidden ### Requirement: Platform Admin Global Access @@ -305,8 +336,8 @@ Platform administrators SHALL be able to: Platform administrators SHALL NOT be able to: -- Modify gateway configuration (PATCH/PUT operations require `gateway:owner`) -- Grant or revoke RoleBindings (requires `gateway:owner` on that specific gateway) +- Modify gateway configuration (PATCH/PUT operations require `gateway:owner` or `gateway:admin`) +- Grant or revoke RoleBindings (requires `gateway:owner` or `gateway:admin` on that specific gateway) - Create gateways (requires `gateway:creator` role) The `platform:admin` role is orthogonal to `gateway:creator`, `gateway:owner`, and diff --git a/specs/web-console/architecture.spec.md b/specs/web-console/architecture.spec.md index eea1d507e..372c82acb 100644 --- a/specs/web-console/architecture.spec.md +++ b/specs/web-console/architecture.spec.md @@ -682,7 +682,7 @@ Before a feature relies on them, the HyperShell API SHALL define and test: - a gateway connection list containing a stable identifier, display name, readiness, creation timestamp, gateway endpoint, console URL, OIDC issuer, OIDC client ID, and OIDC audience; - gateway list, search, pagination, sort, and filter semantics, including page size (default 20, max 100), page number, search query (filters by name, ID, or fleet), sort field and direction, and response metadata (page, size, total count); -- platform:admin users SHALL receive all gateways; other users SHALL receive only gateways where they have `gateway:owner` or `gateway:viewer` bindings; +- platform:admin users SHALL receive all gateways; other users SHALL receive only gateways where they have `gateway:owner`, `gateway:admin`, or `gateway:viewer` bindings; - a managed-cluster placement list containing a stable identifier, display name, provider, region, and status; - gateway provisioning, renaming, and deletion contracts; - gateway-scoped service-account list, create, get, revoke, and delete contracts; diff --git a/specs/web-console/gateway-access-management.spec.md b/specs/web-console/gateway-access-management.spec.md new file mode 100644 index 000000000..546c75bf1 --- /dev/null +++ b/specs/web-console/gateway-access-management.spec.md @@ -0,0 +1,197 @@ +# Gateway Access Management Console Specification + +**Date:** 2026-10-05 +**Status:** Draft +**Applies to:** `packages/gateway-management-ui` (gateway detail pages, application ports, probes), `components/web-console` (host routes, API adapter, composition root, BFF) +**Parent:** `web-console/architecture.spec.md` -- console stack and trust boundaries +**Related:** `platform/gateway-access-management.spec.md` -- the access API, roles, directory search, and creator protection this surface consumes; `web-console/user_flows.md` -- "Sharing a team gateway"; `standards/ui/hexagonal-architecture.spec.md`, `standards/ui/patternfly.spec.md`, `standards/ui/domain-observability.spec.md`, `standards/ui/interaction.spec.md`, `standards/ui/content-localization.spec.md`, `standards/ui/accessibility.spec.md` + +--- + +## Purpose + +Define the **Manage access** tab on the gateway details page: a console surface where a gateway's administrators see who has access to the gateway, change or revoke that access, and add other users from the Keycloak realm directory. + +This surface is a presentation and workflow layer over the gateway access API (`platform/gateway-access-management.spec.md`). It introduces no new authorization logic; the server is authoritative. It is structurally a sibling of the existing Service Accounts tab and SHALL reuse the same ports, table, query, and dialog patterns rather than introducing new ones. + +## Requirements + +### Requirement: GAM-UI-01 -- Manage Access Tab + +The gateway details page SHALL present a **Manage access** tab adjacent to the **Connection** tab. The tab SHALL be available to any user with access to the gateway; management controls within it SHALL follow GAM-UI-07. + +The tab SHALL be registered in the gateway detail tab set and participate in the existing `?tab=` URL routing and the tab aria-label enumeration. The tab content SHALL mount when selected (consistent with other detail tabs) and SHALL gate its data loading on being active. + +#### Scenario: Tab appears next to Connection + +- GIVEN a user opens a gateway they can access +- WHEN the gateway details page renders +- THEN a **Manage access** tab SHALL appear next to **Connection** +- AND selecting it SHALL load the access list for that gateway + +--- + +### Requirement: GAM-UI-02 -- Access Table + +The Manage access tab SHALL render the gateway's access grants in the canonical shared resource table (PatternFly 6; reuse `ResourceTable`, do not fork a new table). + +Each row SHALL show: + +| Column | Source (GAM-03) | +| --- | --- | +| User name | `name` (display name) | +| User ID | `username` | +| Role | `role` (`admin` or `user`), rendered as an inline control per GAM-UI-05 | +| (row action) | Remove access per GAM-UI-06 | + +The creator's row (`is_creator: true`) SHALL be clearly indicated (for example a "Creator" marker) and its role control and remove action SHALL be disabled with an accessible explanation (GAM-UI-08). + +The table SHALL show localized empty, loading, no-results, and error states and SHALL expose a manual refresh consistent with other gateway resource tables. + +#### Scenario: Access rows render user name, user ID, and role + +- GIVEN the access list returns a creator, one admin, and one user +- WHEN the table renders +- THEN each row SHALL show the display name, username, and role +- AND the creator's row SHALL be marked and its controls disabled + +--- + +### Requirement: GAM-UI-03 -- Search and Role Filter + +The table toolbar SHALL provide a **Find people...** search input and a **role** filter (All, Admin, User). + +Search SHALL be debounced, SHALL cancel superseded in-flight requests, SHALL treat the entered text as a literal, and SHALL issue a bounded number of requests. Search and the role filter SHALL be applied server-side via the access list query (GAM-03). A localized no-results state SHALL offer clearing the filters. + +#### Scenario: Filtering narrows the list + +- GIVEN the gateway has several grants +- WHEN the user types in **Find people...** and selects role **User** +- THEN the table SHALL show only user-tier grants matching the search text +- AND clearing the filters SHALL restore the full list + +--- + +### Requirement: GAM-UI-04 -- Add Users Directory Picker + +The toolbar SHALL provide an **Add users** control that opens a searchable picker of people from the Keycloak realm directory (GAM-09). + +The picker SHALL be a PatternFly typeahead with its own search box, modeled on the existing async typeahead (`gateway-placement-select`): debounced input, cancellation, a loading sentinel, a localized no-results state, and an error state with retry. It SHALL list candidates by display name and username and SHALL allow selecting any realm user, including users who have never signed in to HyperShell. + +Selecting a candidate SHALL open the role assignment modal (GAM-UI-05) for that user. + +#### Scenario: Add users searches the realm directory + +- GIVEN realm user `dana` has never signed in to HyperShell +- WHEN an administrator opens **Add users** and searches `da` +- THEN `dana` SHALL appear as a selectable candidate +- AND selecting `dana` SHALL open the role assignment modal for `dana` + +--- + +### Requirement: GAM-UI-05 -- Role Assignment and Change + +Assigning a role to a newly selected user SHALL use a modal containing a **radio list** of the two roles, **User** and **Admin**, each with a short localized description. Confirming SHALL grant the selected role (GAM-04) and, on success, close the modal and refresh the access list. + +Changing an existing grant's role SHALL use the inline role control in that row (GAM-UI-02). Selecting a different role SHALL change the user's access (GAM-05). The change SHALL reflect in the row on success. + +Role submissions SHALL disable their confirm/select control while in flight and SHALL surface a localized error (keeping the user's selection) on failure. + +#### Scenario: Assign a role via the radio modal + +- GIVEN an administrator selected `dana` from the directory picker +- WHEN the role modal shows **User** and **Admin** radio options and the administrator chooses **Admin** and confirms +- THEN `dana` SHALL be granted admin access +- AND the modal SHALL close and the access list SHALL show `dana` as Admin + +#### Scenario: Change a role inline + +- GIVEN `dana` currently has **Admin** +- WHEN an administrator changes `dana`'s row role to **User** +- THEN `dana`'s access SHALL change to user +- AND the row SHALL show **User** on success + +--- + +### Requirement: GAM-UI-06 -- Remove Access + +Each non-creator row SHALL offer a **Remove access** action that opens a confirmation modal (reusing the lifecycle-dialog pattern) and, on confirm, revokes the grant (GAM-06), then closes and refreshes the list. + +The confirmation SHALL name the user being removed. Removing one's own non-creator access SHALL be permitted. + +#### Scenario: Remove a user's access with confirmation + +- GIVEN `dana` has access to the gateway and is not the creator +- WHEN an administrator removes `dana` and confirms +- THEN `dana`'s access SHALL be revoked +- AND the access list SHALL no longer include `dana` + +--- + +### Requirement: GAM-UI-07 -- Management Controls Gated to Administrators + +Access management controls (Add users, the inline role control, and Remove access) SHALL be presented only to callers the server authorizes to manage access (gateway owner or admin, GAM-08). Viewers SHALL see the access list read-only, without those controls. + +The console SHALL rely on server authorization as the source of truth: it SHALL handle `403` and `409` responses with localized messaging and SHALL NOT present a management outcome the server rejected as if it succeeded. + +#### Scenario: Viewer sees a read-only list + +- GIVEN a viewer opens the Manage access tab +- WHEN the list renders +- THEN no Add users, role-change, or Remove access controls SHALL be offered + +--- + +### Requirement: GAM-UI-08 -- Creator Protection in the UI + +The creator's row SHALL disable the role control and the Remove access action, with an accessible explanation that the gateway creator cannot be removed or demoted (GAM-07). This mirrors the server guarantee rather than replacing it; if the server rejects a creator mutation with `409`, the console SHALL surface a localized explanation. + +#### Scenario: Creator controls are disabled + +- GIVEN the current gateway has a creator row +- WHEN the table renders for an administrator +- THEN the creator's role control and Remove access action SHALL be disabled with an accessible reason + +--- + +### Requirement: GAM-UI-09 -- Hexagonal Boundary and Domain Probes + +Access-management workflows SHALL be expressed as application-owned operations on the existing gateway ports (`GatewayControlPlane` driven port and `GatewayOperations` driving port), named in domain language (for example `listGatewayAccess`, `grantGatewayAccess`, `changeGatewayAccessRole`, `revokeGatewayAccess`, `searchGatewayDirectory`), not as raw RoleBinding CRUD. React, TanStack Query, the generated SDK, and the BFF SHALL remain outside the application core (`standards/ui/hexagonal-architecture.spec.md`). + +Every access operation SHALL route through the shared use-case executor so that each invocation emits exactly one started probe and one terminal probe (succeeded, failed, cancelled, denied, or conflicted), and dependency attempts emit their own, through the existing fan-out probe port. New `action` values SHALL be added to the gateway action catalog and kept in agreement with the probe schema. No `console.*` or vendor telemetry SHALL appear in production code (`standards/ui/domain-observability.spec.md`). Correlation and trace context SHALL propagate through the invocation context on every call. + +The API adapter SHALL map the access endpoints (GAM-03 through GAM-09) to domain types, mapping transport errors to typed operation errors (including `403` -> denied and `409` -> conflicted), and SHALL rely on the server-joined user fields rather than issuing per-row user lookups. + +#### Scenario: An access operation emits one started and one terminal probe + +- WHEN a grant operation runs and the server returns `409` (creator protection) +- THEN exactly one started probe and one terminal `conflicted` probe SHALL be published +- AND no raw console or vendor telemetry SHALL be emitted + +--- + +### Requirement: GAM-UI-10 -- Server-State Freshness and Localization + +The access list SHALL load through TanStack Query keyed by gateway id and the active search/role/pagination parameters, with cancellation via request signals. Mutations (grant, change, revoke) SHALL invalidate the access list on success so the table reflects server state. The query SHALL only run while the tab is active. + +All user-visible strings (tab label, column headers, filter labels, **Find people...**, **Add users**, role names and descriptions, creator marker, confirmations, and error/empty/no-results states) SHALL be defined with `defineMessages` and extracted into the web-console locale catalog. User-supplied display names used as fallbacks SHALL be passed through localized presentation helpers, not concatenated into English strings. + +#### Scenario: Mutations refresh the list + +- GIVEN the access list is displayed +- WHEN a grant, role change, or removal succeeds +- THEN the access list query SHALL be invalidated and the table SHALL reflect the change + +--- + +### Requirement: GAM-UI-11 -- Verification + +The gateway management UI package SHALL include unit/component tests (running the use cases against in-memory/fake adapters) covering: table rendering with creator marking and disabled creator controls; search and role filtering; the directory typeahead including a never-signed-in candidate; the radio role modal grant; inline role change; remove-access confirmation; viewer read-only presentation; and probe emission (one started + one terminal per operation, including `denied`/`conflicted`). + +The production API adapter SHALL have contract tests mapping the access endpoints and their error codes to domain types. Storybook/fixtures SHALL include an access list with a creator, an admin, and a user. + +#### Scenario: CI exercises creator protection and probes in the UI + +- WHEN the UI test suite runs +- THEN it SHALL assert the creator's controls are disabled +- AND assert that a server `409` on a creator mutation yields a localized message and a single terminal `conflicted` probe diff --git a/specs/web-console/user_flows.md b/specs/web-console/user_flows.md index 0d9619e11..84678df45 100644 --- a/specs/web-console/user_flows.md +++ b/specs/web-console/user_flows.md @@ -255,6 +255,9 @@ flowchart LR | Service accounts tab | Expiration | Users with service-account capability | Makes upcoming credential replacement visible | | Service accounts tab | Description, client ID, subject, and creation time | Users with service-account capability | Available in row details; never shows the client secret again | | Service accounts tab | Create, view setup, revoke, and delete actions | Users with the matching capability | Uses server-provided capabilities instead of the user persona | +| Manage access tab | Access list (user name, user ID, role) | All users with gateway access | Lists who can access the gateway; read-only for viewers | +| Manage access tab | Find people... search and role filter | All users with gateway access | Filters the access list by name/user ID and by Admin/User | +| Manage access tab | Add users, inline role change, remove access | Gateway owners and admins | Manage who has access and at what role; the creator row is locked | | Details tab | Active sandbox count | All users with gateway access | Shows current gateway use | | Details tab | Cluster or placement | Developers and Admin | Useful for support and troubleshooting | | Details tab | Endpoint | Developers and Admin | Raw value used by clients and support workflows | @@ -354,29 +357,24 @@ flowchart LR **user(s)**: Team - non-developer, Team - developer **description**: Gateway owner grants another user viewer or owner access to a shared gateway. -### questions - - Is access granted to individual users, identity-provider groups, or both? - - Who can add or remove team members? - - Does a team need a role between viewer and owner? - - This is a proposed page or panel. The current authorization model grants access through individual gateway RoleBindings. +### decisions + - Access is granted to **individual users** from the Keycloak realm directory. Identity-provider **group** access remains out of scope (see `platform/gateway-access-management.spec.md` Non-Goals). + - **Gateway owners and gateway admins** can add, change, and remove access. Viewers see the list read-only. + - Roles are **Admin** and **User** (mapping to `gateway:admin`/`gateway:viewer`). The creator is `gateway:owner`, shown as Admin, and cannot be removed or demoted. -### Page: Gateway access +### Page: Manage access -This is a proposed page or another tab on Gateway details. The fields are the same either way. The current API supports individual user bindings; identity-provider group access is still unresolved. +This is the **Manage access** tab on Gateway details (next to Connection). It is specified in `web-console/gateway-access-management.spec.md`; the API it consumes is in `platform/gateway-access-management.spec.md`. | Field or control | Who sees it | Notes | | --- | --- | --- | -| Gateway name | Team users with gateway access | Keeps the access list tied to the correct gateway | -| Gateway owner | Team users with gateway access | Clarifies who can manage access | -| User search | Gateway owners | Finds the individual identity receiving access | -| Group search | Gateway owners, if group access is supported | Proposed; depends on identity-provider integration | -| Identity name and type | Team users with gateway access | Distinguishes users from identity-provider groups if both are supported | -| Role | Team users with gateway access | Shows Viewer or Owner; owners can change it | -| Granted by and granted date | Gateway owners | Useful for understanding how access was added | -| Add access | Gateway owners | Grants the selected identity a role | -| Change role | Gateway owners | Updates existing access | -| Remove access | Gateway owners | Requires confirmation, especially when removing another owner | -| Error and recovery guidance | Gateway owners | Keeps the selected identity and role when a request fails | +| User name and user ID | Team users with gateway access | Shows each person's display name and username (the user ID column) | +| Find people... search | Team users with gateway access | Filters the access list by name or user ID | +| Role filter | Team users with gateway access | Filters the list to Admin or User | +| Role | Team users with gateway access | Shows Admin or User; owners and admins can change it inline. The creator row is marked and its role is locked | +| Add users (directory picker) | Gateway owners and admins | Searches the Keycloak realm directory (including users who have never signed in) and opens a role modal | +| Remove access | Gateway owners and admins | Requires confirmation. The creator cannot be removed | +| Error and recovery guidance | Gateway owners and admins | Keeps the selected identity and role when a request fails | ```mermaid --- @@ -386,11 +384,11 @@ config: flowchart LR user["👤"] --> login["Login (RH auth)"] login --> gateway["Open team gateway"] - gateway --> manage["Select proposed Manage access"] - manage --> owner{"Gateway owner?"} - owner -- No --> denied["Explain that owner access is required"] - owner -- Yes --> find["Find user or group"] - find --> role["Select Viewer or Owner"] + gateway --> manage["Select Manage access tab"] + manage --> owner{"Owner or admin?"} + owner -- No --> denied["Show read-only access list"] + owner -- Yes --> find["Find user in directory"] + find --> role["Select User or Admin"] role --> grant["Grant access"] grant --> success{"Access granted?"} success -- No --> error["Show error and keep selections"] From 11d5d295a280aadcf37464527177e2c675f08748 Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Tue, 6 Oct 2026 13:03:03 -0400 Subject: [PATCH 02/23] docs(gateway-access): grantable owners with delete; last-owner protection Rework the access role model from 'owner = immutable creator' to a three-tier hierarchy Owner > Admin > Viewer. - gateway:owner is now grantable (multiple owners allowed), can delete the gateway, and can grant/change/revoke any role including owner. - gateway:admin manages the Admin and User tiers only (no delete, cannot touch the Owner tier). - Replace creator-immutability with last-owner protection: a gateway must always keep at least one owner; the sole/last owner cannot be demoted or revoked (409). The creator remains an informational is_creator marker only. - Owner-tier operations (assign/promote/demote/revoke owner) require the caller to be an owner (403 otherwise). - Propagate three tiers through the access list/filter, grant/change APIs and hsctl --role, the radio role modal (Owner shown to owners only), UI row gating, visibility, and verification. Updates platform/gateway-access-management, web-console/gateway-access-management, security/rbac-enforcement, platform/openshell-gateway-keycloak, platform/data-model, and web-console/user_flows. Co-Authored-By: Claude Opus 4.8 --- specs/platform/data-model.spec.md | 10 +- .../gateway-access-management.spec.md | 160 +++++++++++------- .../openshell-gateway-keycloak.spec.md | 7 +- specs/security/rbac-enforcement.spec.md | 57 ++++--- .../gateway-access-management.spec.md | 79 +++++---- specs/web-console/user_flows.md | 12 +- 6 files changed, 194 insertions(+), 131 deletions(-) diff --git a/specs/platform/data-model.spec.md b/specs/platform/data-model.spec.md index fd08446aa..7655926e1 100644 --- a/specs/platform/data-model.spec.md +++ b/specs/platform/data-model.spec.md @@ -7,7 +7,7 @@ The HyperShell API server provides a control plane for deploying and managing distributed API gateways across multiple Kubernetes clusters and cloud providers. -Gateways, clusters, releases, and networks are **top-level resources**. An earlier model included a top-level "Sector" (later renamed "Fleet") organizational unit that grouped these resources via a `fleet_id`; that layer has been removed. There is no sectorization: all gateways belong to the same platform, and tenancy is enforced by RBAC (platform-level `gateway:creator`/`platform:admin` and per-gateway `gateway:owner` (the creator), `gateway:admin` (granted administrators), and `gateway:viewer`), not by a fleet grouping. See [`security/rbac-enforcement.spec.md`](../security/rbac-enforcement.spec.md) and [`gateway-access-management.spec.md`](gateway-access-management.spec.md). +Gateways, clusters, releases, and networks are **top-level resources**. An earlier model included a top-level "Sector" (later renamed "Fleet") organizational unit that grouped these resources via a `fleet_id`; that layer has been removed. There is no sectorization: all gateways belong to the same platform, and tenancy is enforced by RBAC (platform-level `gateway:creator`/`platform:admin` and per-gateway `gateway:owner` (full control incl. delete; grantable, one or more), `gateway:admin` (granted administrators; no delete), and `gateway:viewer`), not by a fleet grouping. See [`security/rbac-enforcement.spec.md`](../security/rbac-enforcement.spec.md) and [`gateway-access-management.spec.md`](gateway-access-management.spec.md). Current model: @@ -414,13 +414,13 @@ The `hsctl` CLI mirrors the REST API 1-for-1. Every REST operation has a corresp | `GET /api/hypershell/v1/role_bindings/{id}` | `hsctl get roleBinding ` | ✅ implemented | | `POST /api/hypershell/v1/role_bindings` | `hsctl create roleBinding --role-id --scope [--user-id ]` | ✅ implemented | | `DELETE /api/hypershell/v1/role_bindings/{id}` | `hsctl delete roleBinding ` | ✅ implemented | -| `GET /api/hypershell/v1/gateways/{gateway_id}/access` | `hsctl list gatewayAccess --gateway-id [--role ] [--search ]` | planned (see [`gateway-access-management.spec.md`](gateway-access-management.spec.md) GAM-03) | -| `POST /api/hypershell/v1/gateways/{gateway_id}/access` | `hsctl create gatewayAccess --gateway-id --user --role ` | planned (GAM-04) | -| `PATCH /api/hypershell/v1/gateways/{gateway_id}/access/{user_id}` | `hsctl update gatewayAccess --gateway-id --role ` | planned (GAM-05) | +| `GET /api/hypershell/v1/gateways/{gateway_id}/access` | `hsctl list gatewayAccess --gateway-id [--role ] [--search ]` | planned (see [`gateway-access-management.spec.md`](gateway-access-management.spec.md) GAM-03) | +| `POST /api/hypershell/v1/gateways/{gateway_id}/access` | `hsctl create gatewayAccess --gateway-id --user --role ` | planned (GAM-04) | +| `PATCH /api/hypershell/v1/gateways/{gateway_id}/access/{user_id}` | `hsctl update gatewayAccess --gateway-id --role ` | planned (GAM-05) | | `DELETE /api/hypershell/v1/gateways/{gateway_id}/access/{user_id}` | `hsctl delete gatewayAccess --gateway-id ` | planned (GAM-06) | | `GET /api/hypershell/v1/gateways/{gateway_id}/access/directory` | -- (web console only) | planned Keycloak realm user search (GAM-09); serves the "Add users" picker, no CLI command | -The gateway access facade is a gateway-scoped, enriched projection over the `role_bindings` resource above: `admin` grants write `gateway:admin` bindings, `user` grants write `gateway:viewer` bindings, and the creator's immutable `gateway:owner` binding cannot be granted, changed, or revoked. Granting access to a Keycloak realm user with no existing `User` record pre-provisions one (upsert keyed on `username`); the public users API remains read-only (see [`registered-users.spec.md`](registered-users.spec.md)). +The gateway access facade is a gateway-scoped, enriched projection over the `role_bindings` resource above: `owner` grants write `gateway:owner` bindings, `admin` grants write `gateway:admin` bindings, and `user` grants write `gateway:viewer` bindings. Only owners may assign, change, or revoke the owner tier; a gateway always keeps at least one owner (last-owner protection). Granting access to a Keycloak realm user with no existing `User` record pre-provisions one (upsert keyed on `username`); the public users API remains read-only (see [`registered-users.spec.md`](registered-users.spec.md)). #### Users diff --git a/specs/platform/gateway-access-management.spec.md b/specs/platform/gateway-access-management.spec.md index a7947ac0b..734b23a5f 100644 --- a/specs/platform/gateway-access-management.spec.md +++ b/specs/platform/gateway-access-management.spec.md @@ -14,8 +14,8 @@ This specification defines **gateway access management**: how a gateway's admini Access is already modeled as per-gateway `RoleBinding` records and already projected into Keycloak by the control plane's OIDC Role Bridge (`openshell-gateway-keycloak.spec.md`). This specification: -1. Adds a third per-gateway role, `gateway:admin`, so a **granted administrator** is distinct from the **creator** (`gateway:owner`). -2. Defines a gateway-scoped **access facade** (`/api/hypershell/v1/gateways/{gateway_id}/access`) that presents enriched access grants, atomic role changes, and creator protection in application language, over the existing RoleBinding storage and watch events. +1. Adds a third per-gateway role, `gateway:admin`, forming the hierarchy **Owner > Admin > Viewer**. Owners may delete the gateway and assign other owners; admins manage the Admin and User tiers only. +2. Defines a gateway-scoped **access facade** (`/api/hypershell/v1/gateways/{gateway_id}/access`) that presents enriched access grants, atomic role changes, and last-owner protection in application language, over the existing RoleBinding storage and watch events. 3. Defines a **Keycloak directory search** so administrators can grant access to any user in the Keycloak realm, including users who have never signed in to HyperShell, pre-provisioning a HyperShell `User` record on grant. 4. Aligns the Keycloak Role Bridge documentation with the implemented behavior (owner and admin both receive `openshell-admin` and `openshell-user`). @@ -23,18 +23,21 @@ Access is already modeled as per-gateway `RoleBinding` records and already proje The management plane distinguishes three per-gateway roles. The gateway (and therefore the console and the `openshell` CLI) distinguishes only two tiers, **Admin** and **User**, which correspond to the Keycloak client roles `openshell-admin` and `openshell-user`. -| Management-plane role | Who holds it | Console label | Keycloak client roles | -| --- | --- | --- | --- | -| `gateway:owner` | The creator; exactly one per gateway; immutable | Admin | `openshell-admin`, `openshell-user` | -| `gateway:admin` | A granted administrator; zero or more; removable | Admin | `openshell-admin`, `openshell-user` | -| `gateway:viewer` | A granted standard user; zero or more; removable | User | `openshell-user` | +The three per-gateway roles form a strict hierarchy, **Owner > Admin > Viewer**: -`gateway:owner` and `gateway:admin` are indistinguishable to the gateway: both are gateway administrators. The distinction exists only in the management plane, to protect the creator (see GAM-07) and to keep ownership singular. +| Management-plane role | Who holds it | Console label | Capabilities beyond the lower tier | Keycloak client roles | +| --- | --- | --- | --- | --- | +| `gateway:owner` | One or more; grantable; the creator is the first owner | Owner | Delete the gateway; grant/change/revoke any role including `gateway:owner` | `openshell-admin`, `openshell-user` | +| `gateway:admin` | Zero or more; grantable; removable | Admin | Read/update the gateway; grant/change/revoke `gateway:admin` and `gateway:viewer` | `openshell-admin`, `openshell-user` | +| `gateway:viewer` | Zero or more; grantable; removable | User | Read-only | `openshell-user` | + +`gateway:owner` and `gateway:admin` are indistinguishable **to the gateway**: both are gateway administrators and receive the same Keycloak client roles. Their difference is entirely a management-plane concern -- only owners may delete the gateway and assign other owners. A gateway always retains at least one owner (GAM-07). + +The creator (the auto-provisioned first owner, `security/rbac-enforcement.spec.md`) is tracked for display and audit (`is_creator`, "Created by") but holds no capability beyond any other owner; ownership is shared and transferable among owners. ## Non-Goals - Identity-provider **group** grants. Access is granted to individual users only. Group-based access remains unresolved (`web-console/user_flows.md`). -- **Ownership transfer.** The `gateway:owner` binding is immutable for the life of the gateway; reassigning the creator is out of scope. - Changing how `platform:admin`, `gateway:creator`, or Keycloak realm roles are assigned. Those remain Keycloak-sourced (`security/rbac-enforcement.spec.md`). - Using Keycloak Organizations (the Keycloak feature). "Keycloak organization" in requirements means the configured realm's user directory; tenancy stays RBAC-based. @@ -42,15 +45,15 @@ The management plane distinguishes three per-gateway roles. The gateway (and the ### Requirement: GAM-01 -- Granted Administrator Role -The platform SHALL define a per-gateway built-in role `gateway:admin` representing a granted gateway administrator. +The platform SHALL define a per-gateway built-in role `gateway:admin` representing a granted gateway administrator, below `gateway:owner` in the hierarchy. -`gateway:admin` SHALL confer, on its bound gateway, the same capabilities as `gateway:owner` EXCEPT deleting the gateway: +`gateway:admin` SHALL confer, on its bound gateway: - Read and update the gateway (including rename). -- Manage access: grant, change, and revoke `gateway:admin` and `gateway:viewer` bindings (GAM-08). +- Manage access **for the Admin and User tiers only**: grant, change, and revoke `gateway:admin` and `gateway:viewer` bindings (GAM-08). - Create and manage all OpenShellGatewayServiceAccounts on the gateway, selecting `openshell-user` or `openshell-admin` (GAM-12). -`gateway:admin` SHALL NOT authorize deleting the gateway. Deleting a gateway SHALL require `gateway:owner` or `platform:admin`. +`gateway:admin` SHALL NOT delete the gateway, and SHALL NOT grant, change, or revoke the Owner tier (`gateway:owner`). Deleting a gateway SHALL require `gateway:owner` or `platform:admin`; assigning an owner SHALL require `gateway:owner` (GAM-08). `gateway:admin` SHALL be stored and granted exactly like other per-gateway bindings: a `RoleBinding` with `scope=gateway` and a `gateway_id`. It SHALL NOT be a JWT-synced role and SHALL NOT be assignable via Keycloak realm roles. @@ -121,20 +124,20 @@ Each grant item SHALL expose: | `username` | string | `User.username` (= Keycloak `preferred_username`); the console "User ID" column | | `name` | string | `User.name` display name, when present; the console "User name" column | | `email` | string | Optional | -| `role` | string | `admin` or `user` (the console tier); `admin` covers both `gateway:owner` and `gateway:admin` | -| `is_creator` | boolean | `true` for the single `gateway:owner` grant | +| `role` | string | `owner`, `admin`, or `user` (the console tier) mapping to `gateway:owner`, `gateway:admin`, `gateway:viewer` | +| `is_creator` | boolean | `true` for the owner who created the gateway (informational only; see GAM-07) | | `granted_at` | date-time | Binding creation time | -The response SHALL be assembled server-side with the user fields already joined; it SHALL NOT require the caller to perform one lookup per row. The endpoint SHALL support `search` (case-insensitive substring over `username` and `name`), a `role` filter (`admin` or `user`), pagination, and ordering, following the shared list contract. Search terms SHALL be treated as literals (wildcard/escape semantics handled server-side). +The response SHALL be assembled server-side with the user fields already joined; it SHALL NOT require the caller to perform one lookup per row. The endpoint SHALL support `search` (case-insensitive substring over `username` and `name`), a `role` filter (`owner`, `admin`, or `user`), pagination, and ordering, following the shared list contract. Search terms SHALL be treated as literals (wildcard/escape semantics handled server-side). Any caller with a binding on the gateway (owner, admin, or viewer) or `platform:admin` SHALL be authorized to read the access list. Callers with no access to the gateway SHALL receive `404` (existence not disclosed, per `security/rbac-enforcement.spec.md`). #### Scenario: Owner lists access -- GIVEN gw-1 has a creator (owner), one `gateway:admin`, and two `gateway:viewer` grants -- WHEN the owner calls `GET /api/hypershell/v1/gateways/gw-1/access` -- THEN the response SHALL contain four items with `username`, `name`, `role`, and `is_creator` populated -- AND exactly one item SHALL have `is_creator: true` and `role: admin` +- GIVEN gw-1 has a creator (owner), one additional `gateway:owner`, one `gateway:admin`, and two `gateway:viewer` grants +- WHEN an owner calls `GET /api/hypershell/v1/gateways/gw-1/access` +- THEN the response SHALL contain five items with `username`, `name`, `role`, and `is_creator` populated +- AND exactly one item SHALL have `is_creator: true` and `role: owner` #### Scenario: Filter by role and search by name @@ -151,9 +154,9 @@ Any caller with a binding on the gateway (owner, admin, or viewer) or `platform: ### Requirement: GAM-04 -- Grant Access -The API server SHALL expose `POST /api/hypershell/v1/gateways/{gateway_id}/access` to grant a user `admin` or `user` access to the gateway. +The API server SHALL expose `POST /api/hypershell/v1/gateways/{gateway_id}/access` to grant a user `owner`, `admin`, or `user` access to the gateway. -The request SHALL identify the target user by a directory identity (`username`, or an equivalent Keycloak subject reference returned by GAM-09) and a `role` of `admin` or `user`. Granting `admin` SHALL create a `gateway:admin` binding; granting `user` SHALL create a `gateway:viewer` binding. The endpoint SHALL NOT create `gateway:owner` bindings (GAM-07). +The request SHALL identify the target user by a directory identity (`username`, or an equivalent Keycloak subject reference returned by GAM-09) and a `role` of `owner`, `admin`, or `user`. Granting `owner` SHALL create a `gateway:owner` binding; granting `admin` SHALL create a `gateway:admin` binding; granting `user` SHALL create a `gateway:viewer` binding. Granting (or changing to) the `owner` tier SHALL require the caller to be a `gateway:owner` (GAM-08); `gateway:admin` callers SHALL NOT assign owners. When the target identity has no existing HyperShell `User` record, the API server SHALL **pre-provision** one from the Keycloak directory record (upsert keyed on `username`, populating `username`, `email`, and `name`) before creating the binding, so the binding references a real `user_id` and the Role Bridge can resolve the Keycloak user by username. Pre-provisioning SHALL use the same upsert semantics as JWT auto-provisioning (`security/rbac-enforcement.spec.md`) and SHALL NOT expand the public users API, which remains read-only (`registered-users.spec.md`). @@ -174,20 +177,33 @@ Granting SHALL be idempotent with respect to the effective role (GAM-10). On suc - AND SHALL create a `gateway:viewer` binding for `dana` on gw-1 - AND the Role Bridge SHALL assign `openshell-user` to `dana` on gw-1 -#### Scenario: Cannot grant the owner role +#### Scenario: Owner assigns another owner + +- GIVEN user A is an owner of gw-1 +- WHEN user A grants user E the `owner` role +- THEN a `gateway:owner` binding SHALL be created for E on gw-1 +- AND E SHALL be able to delete gw-1 and assign further owners + +#### Scenario: Admin cannot assign an owner + +- GIVEN user B holds `gateway:admin` on gw-1 (not an owner) +- WHEN user B grants user E the `owner` role +- THEN the request SHALL be rejected with `403` -- WHEN a caller posts an access grant with a role that is not `admin` or `user` (for example attempting to grant owner) +#### Scenario: Unknown role is rejected + +- WHEN a caller posts an access grant with a role that is not `owner`, `admin`, or `user` - THEN the request SHALL be rejected with `400` --- ### Requirement: GAM-05 -- Change Role -The API server SHALL expose `PATCH /api/hypershell/v1/gateways/{gateway_id}/access/{user_id}` to change a user's access between `admin` and `user`. +The API server SHALL expose `PATCH /api/hypershell/v1/gateways/{gateway_id}/access/{user_id}` to change a user's access among `owner`, `admin`, and `user`. -The change SHALL be atomic: the user SHALL NOT be left with zero gateway access or with conflicting bindings at any observable point. The server SHALL converge the user's gateway bindings to exactly the requested tier (a single `gateway:admin` or `gateway:viewer` binding) within one transaction, emitting watch events such that the Role Bridge reconciles to the correct Keycloak client-role union (GAM-02). +The change SHALL be atomic: the user SHALL NOT be left with zero gateway access or with conflicting bindings at any observable point. The server SHALL converge the user's gateway bindings to exactly the requested tier (a single `gateway:owner`, `gateway:admin`, or `gateway:viewer` binding) within one transaction, emitting watch events such that the Role Bridge reconciles to the correct Keycloak client-role union (GAM-02). -Changing the creator's role SHALL be rejected (GAM-07). Changing to a role the user already holds SHALL be a no-op success (GAM-10). +Promoting a user to `owner`, or demoting a user from `owner`, SHALL require the caller to be a `gateway:owner` (GAM-08). Demoting an owner SHALL be rejected if that owner is the last remaining owner (GAM-07). Changing to a role the user already holds SHALL be a no-op success (GAM-10). #### Scenario: Promote a user to admin @@ -196,6 +212,18 @@ Changing the creator's role SHALL be rejected (GAM-07). Changing to a role the u - THEN B SHALL hold exactly `gateway:admin` on gw-1 afterward - AND the Role Bridge SHALL add `openshell-admin` for B on gw-1 +#### Scenario: Owner promotes an admin to owner + +- GIVEN user B holds `gateway:admin` on gw-1 and user A is an owner +- WHEN user A PATCHes B's access to `owner` +- THEN B SHALL hold exactly `gateway:owner` on gw-1 afterward + +#### Scenario: Admin cannot promote to owner + +- GIVEN user B holds `gateway:admin` on gw-1 (not an owner) and user C holds `gateway:viewer` +- WHEN user B PATCHes C's access to `owner` +- THEN the response SHALL be `403` + #### Scenario: Role change never drops access mid-flight - GIVEN user B holds `gateway:admin` on gw-1 @@ -209,54 +237,67 @@ Changing the creator's role SHALL be rejected (GAM-07). Changing to a role the u The API server SHALL expose `DELETE /api/hypershell/v1/gateways/{gateway_id}/access/{user_id}` to revoke a user's access to the gateway. -Revocation SHALL remove the user's `gateway:admin` and/or `gateway:viewer` bindings on the gateway and SHALL cause the Role Bridge to remove the corresponding Keycloak client roles for that user on that gateway's client. Revoking the creator SHALL be rejected (GAM-07). +Revocation SHALL remove the user's `gateway:owner`, `gateway:admin`, and/or `gateway:viewer` bindings on the gateway and SHALL cause the Role Bridge to remove the corresponding Keycloak client roles for that user on that gateway's client. Revoking a user who holds `gateway:owner` SHALL require the caller to be a `gateway:owner` (GAM-08), and SHALL be rejected if that user is the last remaining owner (GAM-07). #### Scenario: Revoke a granted admin - GIVEN user B holds `gateway:admin` on gw-1 -- WHEN an owner of gw-1 revokes B's access +- WHEN an owner or admin of gw-1 revokes B's access - THEN B SHALL hold no binding on gw-1 - AND the Role Bridge SHALL remove `openshell-admin` and `openshell-user` for B on gw-1's Keycloak client -#### Scenario: A granted admin may revoke their own access +#### Scenario: An owner may revoke their own access when another owner remains + +- GIVEN users A and E both hold `gateway:owner` on gw-1 +- WHEN user A revokes their own access +- THEN the request SHALL succeed and A SHALL lose access to gw-1 +- AND E SHALL remain an owner -- GIVEN user B holds `gateway:admin` on gw-1 and is not the creator -- WHEN user B revokes their own access -- THEN the request SHALL succeed and B SHALL lose access to gw-1 +#### Scenario: Admin cannot revoke an owner + +- GIVEN user B holds `gateway:admin` on gw-1 and user A holds `gateway:owner` +- WHEN user B revokes user A's access +- THEN the response SHALL be `403` --- -### Requirement: GAM-07 -- Creator Protection +### Requirement: GAM-07 -- Last-Owner Protection -The `gateway:owner` binding (the creator, auto-created at gateway creation per `security/rbac-enforcement.spec.md`) SHALL be immutable for the life of the gateway. +A gateway SHALL always have at least one `gateway:owner`. The access facade SHALL reject any operation that would remove the last remaining owner, so a gateway can never be orphaned (left with no owner who can delete it or manage ownership): -The access facade SHALL: +- Revoking (`DELETE`) a user who is the only remaining owner SHALL be rejected with `409 Conflict`. +- Demoting (`PATCH` to `admin` or `user`) a user who is the only remaining owner SHALL be rejected with `409 Conflict`. -- Reject creating a second `gateway:owner` binding (`400`); the console grants `admin` as `gateway:admin`, never `gateway:owner`. -- Reject changing the creator's role (`PATCH` targeting the creator) with `409 Conflict`. -- Reject revoking the creator (`DELETE` targeting the creator) with `409 Conflict`. +These rejections SHALL apply regardless of the caller, including an owner acting on themselves. When more than one owner exists, any owner (including the creator) MAY be demoted or revoked by an owner. Ownership is therefore shared and transferable: the creator is not uniquely protected once another owner exists; it is the **last owner** that cannot be removed. -These rejections SHALL apply regardless of the caller, including the creator acting on themselves and any other administrator. As a consequence, a creator cannot remove or demote themselves, and a gateway always retains exactly one owner until it is deleted. +The creator (the auto-provisioned first owner) is retained as `is_creator` for display and audit only and SHALL NOT, by itself, confer immutability. -#### Scenario: Creator cannot demote themselves +#### Scenario: Sole owner cannot demote themselves -- GIVEN user A is the creator (owner) of gw-1 +- GIVEN user A is the only owner of gw-1 - WHEN user A PATCHes their own access to `user` - THEN the response SHALL be `409 Conflict` - AND user A SHALL remain `gateway:owner` -#### Scenario: Creator cannot be removed by another admin +#### Scenario: Sole owner cannot be removed -- GIVEN user A is the creator of gw-1 and user B has `gateway:admin` on gw-1 -- WHEN user B revokes user A's access +- GIVEN user A is the only owner of gw-1 +- WHEN any caller revokes user A's access - THEN the response SHALL be `409 Conflict` - AND user A SHALL remain `gateway:owner` +#### Scenario: An owner may be removed when another owner remains + +- GIVEN users A and E both hold `gateway:owner` on gw-1 +- WHEN an owner demotes A to `user` +- THEN the request SHALL succeed and A SHALL hold `gateway:viewer` +- AND E SHALL remain the owner + --- ### Requirement: GAM-08 -- Access Management Authorization -Granting, changing, and revoking access (GAM-04, GAM-05, GAM-06) and searching the directory (GAM-09) SHALL require `gateway:owner` or `gateway:admin` on the target gateway. +Granting, changing, and revoking access (GAM-04, GAM-05, GAM-06) and searching the directory (GAM-09) SHALL require `gateway:owner` or `gateway:admin` on the target gateway. Operations that touch the **Owner tier** -- granting `owner`, promoting to `owner`, or demoting/revoking a user who holds `gateway:owner` -- SHALL additionally require the caller to be a `gateway:owner`. A `gateway:admin` caller MAY manage only the Admin and User tiers. `gateway:viewer` callers SHALL NOT manage access or search the directory. `platform:admin` SHALL NOT grant, change, or revoke access unless it also holds `gateway:owner` or `gateway:admin` on that gateway (consistent with `platform:admin` being view/delete-only for RBAC grants in `security/rbac-enforcement.spec.md`). Unauthorized management attempts SHALL return `403`; callers with no access to the gateway SHALL receive `404`. @@ -335,7 +376,7 @@ OpenShellGatewayServiceAccount creation (`openshell-gateway-service-accounts.spe Introducing `gateway:admin` SHALL be an additive, idempotent migration. The `gateway:admin` role SHALL be seeded with its permission set (gateway read/update, role_bindings create/read/delete/list, service_accounts manage) alongside the existing built-in roles. Existing `gateway:owner`, `gateway:viewer`, `gateway:creator`, and `platform:admin` bindings SHALL be unaffected, and re-running the migration SHALL be safe. -No historical `gateway:owner` binding SHALL be rewritten to `gateway:admin`; existing gateways keep their single owner, and additional administrators are added going forward as `gateway:admin`. +No historical `gateway:owner` binding SHALL be rewritten; existing gateways keep their creator as owner. Going forward, owners may add further owners, admins, or viewers, and admins may add admins or viewers. #### Scenario: Migration seeds the role idempotently @@ -352,14 +393,14 @@ The `hsctl` CLI mirrors the REST API one-for-one (`data-model.spec.md`), as it a | REST operation | `hsctl` command | | --- | --- | -| GAM-03 list access | `hsctl list gatewayAccess --gateway-id [--role ] [--search ]` | -| GAM-04 grant access | `hsctl create gatewayAccess --gateway-id --user --role ` | -| GAM-05 change role | `hsctl update gatewayAccess --gateway-id --role ` | +| GAM-03 list access | `hsctl list gatewayAccess --gateway-id [--role ] [--search ]` | +| GAM-04 grant access | `hsctl create gatewayAccess --gateway-id --user --role ` | +| GAM-05 change role | `hsctl update gatewayAccess --gateway-id --role ` | | GAM-06 revoke access | `hsctl delete gatewayAccess --gateway-id ` | The directory search (GAM-09) is a deliberate exception to the CLI mirror: it serves the web console "Add users" picker and SHALL NOT have an `hsctl` command. CLI callers already supply the username to `create gatewayAccess --user `. -`--role` SHALL accept `admin` or `user` (mapping to `gateway:admin` and `gateway:viewer`). The commands SHALL use the generated SDK and SHALL surface the same authorization and creator-protection outcomes as the REST API -- a `403` (unauthorized management) or `409` (creator mutation) SHALL produce a non-zero exit with a clear message rather than a silent success. These commands manage access only; they SHALL NOT create or modify the creator's `gateway:owner` binding (GAM-07). +`--role` SHALL accept `owner`, `admin`, or `user` (mapping to `gateway:owner`, `gateway:admin`, `gateway:viewer`). The commands SHALL use the generated SDK and SHALL surface the same authorization and protection outcomes as the REST API -- a `403` (unauthorized management, including a non-owner touching the Owner tier) or `409` (last-owner protection) SHALL produce a non-zero exit with a clear message rather than a silent success. These commands assign only the role given by `--role`; assigning or removing the Owner tier requires the caller to be an owner (GAM-08). #### Scenario: Grant then revoke access via hsctl @@ -369,27 +410,28 @@ The directory search (GAM-09) is a deliberate exception to the CLI mirror: it se - WHEN the caller runs `hsctl delete gatewayAccess --gateway-id gw-1` - THEN dana's access SHALL be revoked -#### Scenario: CLI surfaces creator protection +#### Scenario: CLI surfaces last-owner protection -- GIVEN user A is the creator of gw-1 +- GIVEN user A is the only owner of gw-1 - WHEN the caller runs `hsctl delete gatewayAccess --gateway-id gw-1` -- THEN the command SHALL exit non-zero with a message that the gateway creator cannot be removed (`409`) +- THEN the command SHALL exit non-zero with a message that the last owner cannot be removed (`409`) --- ### Requirement: GAM-14 -- Verification -The API server SHALL include integration tests covering: enriched access list with search and role filter; grant to a registered user and to a never-signed-in directory user (pre-provisioning); atomic role change (promote and demote) with no zero-access window; revoke; creator-protection rejections (`409`) for demote and revoke, including by another admin; management authorization (viewer `403`, admin allowed, non-member `404`); directory-search authorization; and idempotent re-grant. +The API server SHALL include integration tests covering: enriched access list with search and role filter across all three tiers; grant (owner, admin, user) to a registered user and to a never-signed-in directory user (pre-provisioning); atomic role change (promote and demote, including to/from owner) with no zero-access window; revoke; last-owner-protection rejections (`409`) for demoting or revoking the sole owner; owner-tier authorization (a non-owner admin assigning, demoting, or revoking an owner returns `403`); management authorization (viewer `403`, admin allowed for admin/user tiers, non-member `404`); directory-search authorization; and idempotent re-grant. -The control plane SHALL include tests covering the `gateway:admin` bridge mapping and reconcile-to-union on create, update, and delete (including demotion stripping `openshell-admin` while retaining `openshell-user`). +The control plane SHALL include tests covering the `gateway:owner` and `gateway:admin` bridge mappings and reconcile-to-union on create, update, and delete (including demotion stripping `openshell-admin` while retaining `openshell-user`). -The `hsctl` CLI SHALL include tests covering the access commands (GAM-13), including a creator-protection failure surfacing as a non-zero exit. +The `hsctl` CLI SHALL include tests covering the access commands (GAM-13), including a last-owner-protection failure surfacing as a non-zero exit. `make generate` SHALL regenerate the Go and TypeScript SDK clients for the new endpoints. -#### Scenario: CI exercises creator protection and demotion +#### Scenario: CI exercises last-owner protection and demotion - GIVEN the integration and control-plane test suites run - WHEN access-management and Role Bridge tests execute -- THEN they SHALL assert `409` on creator demote/revoke +- THEN they SHALL assert `409` on demoting or revoking the sole owner +- AND assert that a non-owner admin touching the Owner tier returns `403` - AND assert that demoting an admin removes `openshell-admin` while keeping `openshell-user` diff --git a/specs/platform/openshell-gateway-keycloak.spec.md b/specs/platform/openshell-gateway-keycloak.spec.md index bb85ac45e..c49be5b65 100644 --- a/specs/platform/openshell-gateway-keycloak.spec.md +++ b/specs/platform/openshell-gateway-keycloak.spec.md @@ -147,9 +147,10 @@ receives only `openshell-user`: | `gateway:admin` | `openshell-admin`, `openshell-user` | The specific bound gateway | | `gateway:viewer` | `openshell-user` | The specific bound gateway | -`gateway:owner` (the creator) and `gateway:admin` (a granted administrator) are -indistinguishable to the gateway; the owner/admin distinction is a management-plane -concern only (see `gateway-access-management.spec.md`). `gateway:creator` is not mapped -- +`gateway:owner` and `gateway:admin` are both gateway administrators and are +indistinguishable to the gateway; the owner/admin distinction (only owners may delete the +gateway or assign owners) is a management-plane concern only (see +`gateway-access-management.spec.md`). `gateway:creator` is not mapped -- it grants the ability to create gateways but does not confer access to any specific gateway. The creator automatically receives a `gateway:owner` RoleBinding on creation (see [`rbac-enforcement.spec.md`](../security/rbac-enforcement.spec.md)), which provides diff --git a/specs/security/rbac-enforcement.spec.md b/specs/security/rbac-enforcement.spec.md index 457a7c547..66c1c9dee 100644 --- a/specs/security/rbac-enforcement.spec.md +++ b/specs/security/rbac-enforcement.spec.md @@ -28,9 +28,9 @@ stranding users before Keycloak is fully configured. It SHALL NOT be set in any production or staging overlay. Users gain gateway access by being assigned `gateway:creator` or `platform:admin` in -Keycloak, or by being granted a per-gateway binding (`gateway:admin`, `gateway:viewer`) -by an existing gateway owner or admin. `gateway:owner` is the creator's binding and is -not granted; see `platform/gateway-access-management.spec.md`. +Keycloak, or by being granted a per-gateway binding (`gateway:owner`, `gateway:admin`, +`gateway:viewer`). Owners can grant any of the three; admins can grant only +`gateway:admin` and `gateway:viewer`. See `platform/gateway-access-management.spec.md`. --- @@ -108,8 +108,8 @@ Role ||--o{ RoleBinding : "granted_by" |------|-------|--------|---------| | `platform:admin` | global | Keycloak JWT | Platform-wide administration; can view and delete any gateway | | `gateway:creator` | global | Keycloak JWT | Can create gateways; auto-becomes `gateway:owner` on creation | -| `gateway:owner` | per gateway | DB (app logic) | The creator; exactly one per gateway; immutable (cannot be granted, demoted, or revoked). Full CRUD including delete; can grant `gateway:admin` and `gateway:viewer` to others | -| `gateway:admin` | per gateway | DB (app logic) | Granted administrator; zero or more; removable. Same as owner except cannot delete the gateway; can grant `gateway:admin` and `gateway:viewer` to others | +| `gateway:owner` | per gateway | DB (app logic) | Gateway owner; one or more; grantable and removable (but a gateway always keeps at least one owner). Full CRUD including delete; can grant/change/revoke any role including `gateway:owner`. The creator is the first owner | +| `gateway:admin` | per gateway | DB (app logic) | Granted administrator; zero or more; removable. Read/update the gateway (no delete); can grant/change/revoke `gateway:admin` and `gateway:viewer` only (not `gateway:owner`) | | `gateway:viewer` | per gateway | DB (app logic) | Read-only access to one gateway | | `managed-cluster-registrar` | global | Keycloak JWT (direct, no DB binding) | Allows a control-plane service account (every control plane, the hub's co-located one included) to call `POST /managed_clusters/registration`; checked live from JWT claim, not via `JWTSyncedRoles` or DB RoleBinding | @@ -118,8 +118,8 @@ Role ||--o{ RoleBinding : "granted_by" | Role | Gateways | Gateway CRUD | RBAC Grants | OpenShell Mapping | OpenShellGatewayServiceAccounts | ManagedCluster Registration | |------|----------|-------------|-------------|-------------------|-----------------|-----------------| | `platform:admin` | view all, delete any | view all + delete any | -- | -- | None without a gateway binding | View all records; create, update, or delete any record | -| `gateway:creator` | create + own gateways | full (as owner) | grant admin/viewer on own gateways | `openshell-admin` + `openshell-user` on own gateways | Through the resulting owner binding | View all records; no writes | -| `gateway:owner` | full (one gateway) | full incl. delete | grant admin/viewer on that gateway | `openshell-admin` + `openshell-user` on that gateway | Select `openshell-user` or `openshell-admin`. Manage all OpenShellGatewayServiceAccounts on the gateway. | -- | +| `gateway:creator` | create + own gateways | full (as owner) | grant owner/admin/viewer on own gateways | `openshell-admin` + `openshell-user` on own gateways | Through the resulting owner binding | View all records; no writes | +| `gateway:owner` | full (one gateway) | full incl. delete | grant owner/admin/viewer on that gateway | `openshell-admin` + `openshell-user` on that gateway | Select `openshell-user` or `openshell-admin`. Manage all OpenShellGatewayServiceAccounts on the gateway. | -- | | `gateway:admin` | full except delete (one gateway) | read + update (no delete) | grant admin/viewer on that gateway | `openshell-admin` + `openshell-user` on that gateway | Select `openshell-user` or `openshell-admin`. Manage all OpenShellGatewayServiceAccounts on the gateway. | -- | | `gateway:viewer` | read (one gateway) | read only | -- | `openshell-user` on that gateway | Select only `openshell-user`. Manage only their own OpenShellGatewayServiceAccounts. | -- | | `managed-cluster-registrar` | none (unless also granted `gateway:creator` via defaults) | none | none | none | none | `POST /registration` (register + heartbeat loop) -- enforced by JWT-direct check in `isAuthorized`, not a DB binding. Once registered, the caller may delete its own record (self-deregistration). | @@ -224,11 +224,12 @@ needed to match the JWT claims. Any authenticated user with the `gateway:creator` role SHALL be able to create gateways. On successful gateway creation, the system SHALL automatically create a `gateway:owner` -RoleBinding for the authenticated user, scoped to the new gateway. This owner binding -identifies the creator; it is the only `gateway:owner` binding on the gateway and is -immutable (it cannot be granted, demoted, or revoked -- see -`platform/gateway-access-management.spec.md` GAM-07). Additional administrators are -added as `gateway:admin`, not `gateway:owner`. +RoleBinding for the authenticated user, scoped to the new gateway. This user is the +creator and the first owner. Owners MAY subsequently grant `gateway:owner`, +`gateway:admin`, or `gateway:viewer` to others, so a gateway may have multiple owners; +a gateway SHALL always retain at least one owner (last-owner protection -- see +`platform/gateway-access-management.spec.md` GAM-07). The creator holds no capability +beyond any other owner and is tracked only for display/audit. This binding is created in the same database transaction as the gateway. @@ -276,13 +277,13 @@ on all gateways the user owns (has a `gateway:owner` binding for). ### Requirement: RoleBinding Grants -Gateway owners and gateway admins can grant `gateway:admin` or `gateway:viewer` to other -users on gateways they administer. There is no hierarchy restriction among administrators --- owners and admins can make more admins. Neither owners nor admins can grant -`gateway:owner`: the owner binding is the creator's and is never granted (see Gateway -Creation Bootstrap and `platform/gateway-access-management.spec.md` GAM-07). The console -performs these grants through the gateway access facade (`GAM-04`/`GAM-05`/`GAM-06`), -which writes the same RoleBinding records. +Grants follow the hierarchy **owner > admin > viewer**. Gateway owners can grant, change, +or revoke any role -- `gateway:owner`, `gateway:admin`, or `gateway:viewer` -- on gateways +they own. Gateway admins can grant, change, or revoke only `gateway:admin` and +`gateway:viewer`; they SHALL NOT assign, change, or revoke the owner tier. A gateway SHALL +always retain at least one owner (last-owner protection, GAM-07). The console performs +these grants through the gateway access facade (`GAM-04`/`GAM-05`/`GAM-06`), which writes +the same RoleBinding records. #### Scenario: Owner invites a viewer @@ -291,24 +292,24 @@ which writes the same RoleBinding records. - THEN the binding is created - AND user B gains read-only access to gw-1 -#### Scenario: Owner invites a co-admin +#### Scenario: Owner assigns a co-owner - GIVEN user A has `gateway:owner` on gw-1 -- WHEN user A grants `role=gateway:admin`, `gateway_id=gw-1`, `user_id=B` +- WHEN user A grants `role=gateway:owner`, `gateway_id=gw-1`, `user_id=B` - THEN the binding is created -- AND user B gains administrative access to gw-1 (everything except deleting the gateway) +- AND user B gains full owner access to gw-1 (including deleting the gateway and assigning owners) -#### Scenario: Admin can grant access +#### Scenario: Admin can grant admin and viewer - GIVEN user B has `gateway:admin` on gw-1 -- WHEN user B grants `role=gateway:viewer`, `gateway_id=gw-1`, `user_id=D` +- WHEN user B grants `role=gateway:viewer` (or `gateway:admin`), `gateway_id=gw-1`, `user_id=D` - THEN the binding is created -#### Scenario: Granting gateway:owner is rejected +#### Scenario: Admin cannot assign an owner -- GIVEN user A has `gateway:owner` on gw-1 -- WHEN user A attempts to grant `role=gateway:owner` on gw-1 -- THEN the request is rejected (the owner binding cannot be granted) +- GIVEN user B has `gateway:admin` on gw-1 (not an owner) +- WHEN user B attempts to grant `role=gateway:owner` on gw-1 +- THEN the request returns 403 Forbidden #### Scenario: Viewer cannot grant diff --git a/specs/web-console/gateway-access-management.spec.md b/specs/web-console/gateway-access-management.spec.md index 546c75bf1..eb348affe 100644 --- a/specs/web-console/gateway-access-management.spec.md +++ b/specs/web-console/gateway-access-management.spec.md @@ -4,7 +4,7 @@ **Status:** Draft **Applies to:** `packages/gateway-management-ui` (gateway detail pages, application ports, probes), `components/web-console` (host routes, API adapter, composition root, BFF) **Parent:** `web-console/architecture.spec.md` -- console stack and trust boundaries -**Related:** `platform/gateway-access-management.spec.md` -- the access API, roles, directory search, and creator protection this surface consumes; `web-console/user_flows.md` -- "Sharing a team gateway"; `standards/ui/hexagonal-architecture.spec.md`, `standards/ui/patternfly.spec.md`, `standards/ui/domain-observability.spec.md`, `standards/ui/interaction.spec.md`, `standards/ui/content-localization.spec.md`, `standards/ui/accessibility.spec.md` +**Related:** `platform/gateway-access-management.spec.md` -- the access API, roles, directory search, and last-owner protection this surface consumes; `web-console/user_flows.md` -- "Sharing a team gateway"; `standards/ui/hexagonal-architecture.spec.md`, `standards/ui/patternfly.spec.md`, `standards/ui/domain-observability.spec.md`, `standards/ui/interaction.spec.md`, `standards/ui/content-localization.spec.md`, `standards/ui/accessibility.spec.md` --- @@ -41,25 +41,25 @@ Each row SHALL show: | --- | --- | | User name | `name` (display name) | | User ID | `username` | -| Role | `role` (`admin` or `user`), rendered as an inline control per GAM-UI-05 | +| Role | `role` (`owner`, `admin`, or `user`), rendered as an inline control per GAM-UI-05 | | (row action) | Remove access per GAM-UI-06 | -The creator's row (`is_creator: true`) SHALL be clearly indicated (for example a "Creator" marker) and its role control and remove action SHALL be disabled with an accessible explanation (GAM-UI-08). +The creator's row (`is_creator: true`) SHALL be indicated (for example a "Creator" marker) for context; this is informational and does not by itself lock the row. A row's role control and remove action SHALL be disabled with an accessible explanation when the server would reject the action (GAM-UI-08): the user is the last remaining owner, or the current caller is not an owner and the row is an owner. The table SHALL show localized empty, loading, no-results, and error states and SHALL expose a manual refresh consistent with other gateway resource tables. #### Scenario: Access rows render user name, user ID, and role -- GIVEN the access list returns a creator, one admin, and one user +- GIVEN the access list returns an owner (the creator), another owner, one admin, and one user - WHEN the table renders -- THEN each row SHALL show the display name, username, and role -- AND the creator's row SHALL be marked and its controls disabled +- THEN each row SHALL show the display name, username, and role (Owner/Admin/User) +- AND the creator's row SHALL carry the Creator marker --- ### Requirement: GAM-UI-03 -- Search and Role Filter -The table toolbar SHALL provide a **Find people...** search input and a **role** filter (All, Admin, User). +The table toolbar SHALL provide a **Find people...** search input and a **role** filter (All, Owner, Admin, User). Search SHALL be debounced, SHALL cancel superseded in-flight requests, SHALL treat the entered text as a literal, and SHALL issue a bounded number of requests. Search and the role filter SHALL be applied server-side via the access list query (GAM-03). A localized no-results state SHALL offer clearing the filters. @@ -91,23 +91,28 @@ Selecting a candidate SHALL open the role assignment modal (GAM-UI-05) for that ### Requirement: GAM-UI-05 -- Role Assignment and Change -Assigning a role to a newly selected user SHALL use a modal containing a **radio list** of the two roles, **User** and **Admin**, each with a short localized description. Confirming SHALL grant the selected role (GAM-04) and, on success, close the modal and refresh the access list. +Assigning a role to a newly selected user SHALL use a modal containing a **radio list** of the roles the current caller may assign, each with a short localized description. Owners SHALL see **User**, **Admin**, and **Owner**; admins (non-owners) SHALL see only **User** and **Admin** (the Owner option SHALL be absent or disabled with an explanation, per GAM-08). Confirming SHALL grant the selected role (GAM-04) and, on success, close the modal and refresh the access list. -Changing an existing grant's role SHALL use the inline role control in that row (GAM-UI-02). Selecting a different role SHALL change the user's access (GAM-05). The change SHALL reflect in the row on success. +Changing an existing grant's role SHALL use the inline role control in that row (GAM-UI-02), offering the same role set the caller may assign. Selecting a different role SHALL change the user's access (GAM-05). The change SHALL reflect in the row on success. Role submissions SHALL disable their confirm/select control while in flight and SHALL surface a localized error (keeping the user's selection) on failure. -#### Scenario: Assign a role via the radio modal +#### Scenario: Owner assigns the Owner role via the radio modal -- GIVEN an administrator selected `dana` from the directory picker -- WHEN the role modal shows **User** and **Admin** radio options and the administrator chooses **Admin** and confirms -- THEN `dana` SHALL be granted admin access -- AND the modal SHALL close and the access list SHALL show `dana` as Admin +- GIVEN an owner selected `dana` from the directory picker +- WHEN the role modal shows **User**, **Admin**, and **Owner** radio options and the owner chooses **Owner** and confirms +- THEN `dana` SHALL be granted owner access +- AND the modal SHALL close and the access list SHALL show `dana` as Owner + +#### Scenario: Admin does not see the Owner option + +- GIVEN a non-owner admin opens the role modal or inline role control +- THEN only **User** and **Admin** SHALL be selectable (no Owner option) #### Scenario: Change a role inline - GIVEN `dana` currently has **Admin** -- WHEN an administrator changes `dana`'s row role to **User** +- WHEN an owner changes `dana`'s row role to **User** - THEN `dana`'s access SHALL change to user - AND the row SHALL show **User** on success @@ -115,13 +120,13 @@ Role submissions SHALL disable their confirm/select control while in flight and ### Requirement: GAM-UI-06 -- Remove Access -Each non-creator row SHALL offer a **Remove access** action that opens a confirmation modal (reusing the lifecycle-dialog pattern) and, on confirm, revokes the grant (GAM-06), then closes and refreshes the list. +Each row the caller may manage SHALL offer a **Remove access** action that opens a confirmation modal (reusing the lifecycle-dialog pattern) and, on confirm, revokes the grant (GAM-06), then closes and refreshes the list. The action SHALL be disabled per GAM-UI-07 (owner rows for non-owners) and GAM-UI-08 (last owner). -The confirmation SHALL name the user being removed. Removing one's own non-creator access SHALL be permitted. +The confirmation SHALL name the user being removed. Removing one's own access SHALL be permitted unless the caller is the last remaining owner (GAM-UI-08). Removing an owner SHALL be offered only to owners. #### Scenario: Remove a user's access with confirmation -- GIVEN `dana` has access to the gateway and is not the creator +- GIVEN `dana` has User access to the gateway - WHEN an administrator removes `dana` and confirms - THEN `dana`'s access SHALL be revoked - AND the access list SHALL no longer include `dana` @@ -132,6 +137,8 @@ The confirmation SHALL name the user being removed. Removing one's own non-creat Access management controls (Add users, the inline role control, and Remove access) SHALL be presented only to callers the server authorizes to manage access (gateway owner or admin, GAM-08). Viewers SHALL see the access list read-only, without those controls. +Controls that touch the **Owner tier** -- assigning the Owner role, or changing/removing a user who is an owner -- SHALL be offered only to owner callers; for non-owner admins these SHALL be absent or disabled with an accessible explanation. + The console SHALL rely on server authorization as the source of truth: it SHALL handle `403` and `409` responses with localized messaging and SHALL NOT present a management outcome the server rejected as if it succeeded. #### Scenario: Viewer sees a read-only list @@ -140,17 +147,29 @@ The console SHALL rely on server authorization as the source of truth: it SHALL - WHEN the list renders - THEN no Add users, role-change, or Remove access controls SHALL be offered +#### Scenario: Admin cannot act on owner rows + +- GIVEN a non-owner admin opens the Manage access tab +- WHEN the table renders +- THEN owner rows SHALL have their role control and Remove access action disabled with an accessible reason + --- -### Requirement: GAM-UI-08 -- Creator Protection in the UI +### Requirement: GAM-UI-08 -- Last-Owner Protection in the UI + +When a user is the **last remaining owner**, that row's role control and Remove access action SHALL be disabled with an accessible explanation that a gateway must keep at least one owner (GAM-07). This mirrors the server guarantee rather than replacing it; if the server rejects a mutation with `409`, the console SHALL surface a localized explanation. When more than one owner exists, owner rows SHALL be demotable/removable by owner callers. -The creator's row SHALL disable the role control and the Remove access action, with an accessible explanation that the gateway creator cannot be removed or demoted (GAM-07). This mirrors the server guarantee rather than replacing it; if the server rejects a creator mutation with `409`, the console SHALL surface a localized explanation. +#### Scenario: Sole owner controls are disabled -#### Scenario: Creator controls are disabled +- GIVEN gw-1 has exactly one owner +- WHEN the table renders +- THEN that owner's role control and Remove access action SHALL be disabled with an accessible reason -- GIVEN the current gateway has a creator row -- WHEN the table renders for an administrator -- THEN the creator's role control and Remove access action SHALL be disabled with an accessible reason +#### Scenario: Owner rows become actionable once a second owner exists + +- GIVEN gw-1 has two owners and the caller is an owner +- WHEN the table renders +- THEN each owner row's role control and Remove access action SHALL be enabled --- @@ -164,7 +183,7 @@ The API adapter SHALL map the access endpoints (GAM-03 through GAM-09) to domain #### Scenario: An access operation emits one started and one terminal probe -- WHEN a grant operation runs and the server returns `409` (creator protection) +- WHEN a grant operation runs and the server returns `409` (last-owner protection) - THEN exactly one started probe and one terminal `conflicted` probe SHALL be published - AND no raw console or vendor telemetry SHALL be emitted @@ -186,12 +205,12 @@ All user-visible strings (tab label, column headers, filter labels, **Find peopl ### Requirement: GAM-UI-11 -- Verification -The gateway management UI package SHALL include unit/component tests (running the use cases against in-memory/fake adapters) covering: table rendering with creator marking and disabled creator controls; search and role filtering; the directory typeahead including a never-signed-in candidate; the radio role modal grant; inline role change; remove-access confirmation; viewer read-only presentation; and probe emission (one started + one terminal per operation, including `denied`/`conflicted`). +The gateway management UI package SHALL include unit/component tests (running the use cases against in-memory/fake adapters) covering: table rendering with creator marking, sole-owner disabled controls, and owner rows disabled for non-owner callers; search and role filtering across the three tiers; the directory typeahead including a never-signed-in candidate; the radio role modal grant including the owner-only Owner option; inline role change; remove-access confirmation; viewer read-only presentation; and probe emission (one started + one terminal per operation, including `denied`/`conflicted`). -The production API adapter SHALL have contract tests mapping the access endpoints and their error codes to domain types. Storybook/fixtures SHALL include an access list with a creator, an admin, and a user. +The production API adapter SHALL have contract tests mapping the access endpoints and their error codes to domain types. Storybook/fixtures SHALL include an access list with two owners (one the creator), an admin, and a user. -#### Scenario: CI exercises creator protection and probes in the UI +#### Scenario: CI exercises last-owner protection and probes in the UI - WHEN the UI test suite runs -- THEN it SHALL assert the creator's controls are disabled -- AND assert that a server `409` on a creator mutation yields a localized message and a single terminal `conflicted` probe +- THEN it SHALL assert the sole owner's controls are disabled +- AND assert that a server `409` on a last-owner mutation yields a localized message and a single terminal `conflicted` probe diff --git a/specs/web-console/user_flows.md b/specs/web-console/user_flows.md index 84678df45..b6d08e1b5 100644 --- a/specs/web-console/user_flows.md +++ b/specs/web-console/user_flows.md @@ -256,8 +256,8 @@ flowchart LR | Service accounts tab | Description, client ID, subject, and creation time | Users with service-account capability | Available in row details; never shows the client secret again | | Service accounts tab | Create, view setup, revoke, and delete actions | Users with the matching capability | Uses server-provided capabilities instead of the user persona | | Manage access tab | Access list (user name, user ID, role) | All users with gateway access | Lists who can access the gateway; read-only for viewers | -| Manage access tab | Find people... search and role filter | All users with gateway access | Filters the access list by name/user ID and by Admin/User | -| Manage access tab | Add users, inline role change, remove access | Gateway owners and admins | Manage who has access and at what role; the creator row is locked | +| Manage access tab | Find people... search and role filter | All users with gateway access | Filters the access list by name/user ID and by Owner/Admin/User | +| Manage access tab | Add users, inline role change, remove access | Gateway owners and admins | Manage who has access and at what role; only owners assign/remove owners, and the last owner is locked | | Details tab | Active sandbox count | All users with gateway access | Shows current gateway use | | Details tab | Cluster or placement | Developers and Admin | Useful for support and troubleshooting | | Details tab | Endpoint | Developers and Admin | Raw value used by clients and support workflows | @@ -360,7 +360,7 @@ flowchart LR ### decisions - Access is granted to **individual users** from the Keycloak realm directory. Identity-provider **group** access remains out of scope (see `platform/gateway-access-management.spec.md` Non-Goals). - **Gateway owners and gateway admins** can add, change, and remove access. Viewers see the list read-only. - - Roles are **Admin** and **User** (mapping to `gateway:admin`/`gateway:viewer`). The creator is `gateway:owner`, shown as Admin, and cannot be removed or demoted. + - Roles are **Owner**, **Admin**, and **User** (mapping to `gateway:owner`/`gateway:admin`/`gateway:viewer`), a hierarchy Owner > Admin > Viewer. Owners can delete the gateway and assign other owners; admins manage Admin/User only. A gateway always keeps at least one owner (the last owner cannot be removed or demoted). The creator is the first owner, marked for context. ### Page: Manage access @@ -371,9 +371,9 @@ This is the **Manage access** tab on Gateway details (next to Connection). It is | User name and user ID | Team users with gateway access | Shows each person's display name and username (the user ID column) | | Find people... search | Team users with gateway access | Filters the access list by name or user ID | | Role filter | Team users with gateway access | Filters the list to Admin or User | -| Role | Team users with gateway access | Shows Admin or User; owners and admins can change it inline. The creator row is marked and its role is locked | +| Role | Team users with gateway access | Shows Owner, Admin, or User; owners change any role, admins change Admin/User. The last owner's role is locked | | Add users (directory picker) | Gateway owners and admins | Searches the Keycloak realm directory (including users who have never signed in) and opens a role modal | -| Remove access | Gateway owners and admins | Requires confirmation. The creator cannot be removed | +| Remove access | Gateway owners and admins | Requires confirmation. Only owners remove owners; the last owner cannot be removed | | Error and recovery guidance | Gateway owners and admins | Keeps the selected identity and role when a request fails | ```mermaid @@ -388,7 +388,7 @@ flowchart LR manage --> owner{"Owner or admin?"} owner -- No --> denied["Show read-only access list"] owner -- Yes --> find["Find user in directory"] - find --> role["Select User or Admin"] + find --> role["Select Owner, Admin, or User"] role --> grant["Grant access"] grant --> success{"Access granted?"} success -- No --> error["Show error and keep selections"] From 6f153bc93707d25218c3d83718b41d61fc103b4c Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Tue, 6 Oct 2026 14:55:22 -0400 Subject: [PATCH 03/23] docs(gateway-access): address PR review feedback - GAM-05: state the role-change mechanism explicitly (in-place role_id update emitting a single UPDATED event, not delete+create); note the generic role_bindings REST stays create/delete-only (addresses role-change/UPDATED clarification). - GAM-03: role_binding_id is informational only; change/revoke are keyed by user_id (resolve field-vs-endpoint inconsistency). - GAM-04: resolve the target against the realm first and reject grants to users absent from the realm with 404 (no pre-provision/binding). - GAM-07: last-owner 409 responses SHALL carry a detailed, actionable error message. - GAM-09: serve the directory from a periodically-refreshed control-plane projection of realm users instead of a live Keycloak query per search; grant re-validates against the realm. - data-model + GAM-14: reflect the in-place update path and the above in notes and verification. Co-Authored-By: Claude Opus 4.8 --- specs/platform/data-model.spec.md | 2 +- .../gateway-access-management.spec.md | 31 ++++++++++++++----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/specs/platform/data-model.spec.md b/specs/platform/data-model.spec.md index 7655926e1..2101d3c92 100644 --- a/specs/platform/data-model.spec.md +++ b/specs/platform/data-model.spec.md @@ -420,7 +420,7 @@ The `hsctl` CLI mirrors the REST API 1-for-1. Every REST operation has a corresp | `DELETE /api/hypershell/v1/gateways/{gateway_id}/access/{user_id}` | `hsctl delete gatewayAccess --gateway-id ` | planned (GAM-06) | | `GET /api/hypershell/v1/gateways/{gateway_id}/access/directory` | -- (web console only) | planned Keycloak realm user search (GAM-09); serves the "Add users" picker, no CLI command | -The gateway access facade is a gateway-scoped, enriched projection over the `role_bindings` resource above: `owner` grants write `gateway:owner` bindings, `admin` grants write `gateway:admin` bindings, and `user` grants write `gateway:viewer` bindings. Only owners may assign, change, or revoke the owner tier; a gateway always keeps at least one owner (last-owner protection). Granting access to a Keycloak realm user with no existing `User` record pre-provisions one (upsert keyed on `username`); the public users API remains read-only (see [`registered-users.spec.md`](registered-users.spec.md)). +The gateway access facade is a gateway-scoped, enriched projection over the `role_bindings` resource above: `owner` grants write `gateway:owner` bindings, `admin` grants write `gateway:admin` bindings, and `user` grants write `gateway:viewer` bindings. Only owners may assign, change, or revoke the owner tier; a gateway always keeps at least one owner (last-owner protection). A role change (`PATCH .../access/{user_id}`) mutates the backing `RoleBinding`'s `role_id` in place within one transaction (emitting a `RoleBinding` `UPDATED` event), rather than delete-then-create; the generic `role_bindings` REST resource above remains create/delete-only, and this in-place update is an internal facade/service operation. Granting access to a Keycloak realm user with no existing `User` record pre-provisions one (upsert keyed on `username`) after resolving the user against the realm directory; the public users API remains read-only (see [`registered-users.spec.md`](registered-users.spec.md)). #### Users diff --git a/specs/platform/gateway-access-management.spec.md b/specs/platform/gateway-access-management.spec.md index 734b23a5f..95d9d4f44 100644 --- a/specs/platform/gateway-access-management.spec.md +++ b/specs/platform/gateway-access-management.spec.md @@ -119,7 +119,7 @@ Each grant item SHALL expose: | Field | Type | Notes | | --- | --- | --- | -| `role_binding_id` | string | The backing RoleBinding id; used by change/revoke | +| `role_binding_id` | string | The backing RoleBinding id; informational only. Change (GAM-05) and revoke (GAM-06) are keyed by `user_id` in the URL and operate on all of that user's bindings on the gateway, not by `role_binding_id` | | `user_id` | string | HyperShell User id | | `username` | string | `User.username` (= Keycloak `preferred_username`); the console "User ID" column | | `name` | string | `User.name` display name, when present; the console "User name" column | @@ -158,7 +158,9 @@ The API server SHALL expose `POST /api/hypershell/v1/gateways/{gateway_id}/acces The request SHALL identify the target user by a directory identity (`username`, or an equivalent Keycloak subject reference returned by GAM-09) and a `role` of `owner`, `admin`, or `user`. Granting `owner` SHALL create a `gateway:owner` binding; granting `admin` SHALL create a `gateway:admin` binding; granting `user` SHALL create a `gateway:viewer` binding. Granting (or changing to) the `owner` tier SHALL require the caller to be a `gateway:owner` (GAM-08); `gateway:admin` callers SHALL NOT assign owners. -When the target identity has no existing HyperShell `User` record, the API server SHALL **pre-provision** one from the Keycloak directory record (upsert keyed on `username`, populating `username`, `email`, and `name`) before creating the binding, so the binding references a real `user_id` and the Role Bridge can resolve the Keycloak user by username. Pre-provisioning SHALL use the same upsert semantics as JWT auto-provisioning (`security/rbac-enforcement.spec.md`) and SHALL NOT expand the public users API, which remains read-only (`registered-users.spec.md`). +The target identity SHALL be resolved against the Keycloak realm directory before any binding is created. If the identity does not correspond to a user in the realm, the request SHALL be rejected with `404 Not Found` and a clear error (the person must exist in the realm to be granted access); no `User` record SHALL be pre-provisioned and no binding SHALL be created. Resolution SHALL reuse the directory projection / lookup of GAM-09. + +When the target identity resolves to a realm user that has no existing HyperShell `User` record, the API server SHALL **pre-provision** one from the Keycloak directory record (upsert keyed on `username`, populating `username`, `email`, and `name`) before creating the binding, so the binding references a real `user_id` and the Role Bridge can resolve the Keycloak user by username. Pre-provisioning SHALL use the same upsert semantics as JWT auto-provisioning (`security/rbac-enforcement.spec.md`) and SHALL NOT expand the public users API, which remains read-only (`registered-users.spec.md`). Granting SHALL be idempotent with respect to the effective role (GAM-10). On success the endpoint SHALL return the resulting grant item (GAM-03 shape). @@ -195,13 +197,22 @@ Granting SHALL be idempotent with respect to the effective role (GAM-10). On suc - WHEN a caller posts an access grant with a role that is not `owner`, `admin`, or `user` - THEN the request SHALL be rejected with `400` +#### Scenario: Grant to a user not in the realm is rejected + +- GIVEN identity `ghost` does not exist in the Keycloak realm +- WHEN an owner of gw-1 grants `ghost` any role +- THEN the request SHALL be rejected with `404` and a clear error +- AND no `User` record SHALL be pre-provisioned and no binding SHALL be created for `ghost` + --- ### Requirement: GAM-05 -- Change Role The API server SHALL expose `PATCH /api/hypershell/v1/gateways/{gateway_id}/access/{user_id}` to change a user's access among `owner`, `admin`, and `user`. -The change SHALL be atomic: the user SHALL NOT be left with zero gateway access or with conflicting bindings at any observable point. The server SHALL converge the user's gateway bindings to exactly the requested tier (a single `gateway:owner`, `gateway:admin`, or `gateway:viewer` binding) within one transaction, emitting watch events such that the Role Bridge reconciles to the correct Keycloak client-role union (GAM-02). +The change SHALL be atomic: the user SHALL NOT be left with zero gateway access or with conflicting bindings at any observable point. The server SHALL converge the user's gateway bindings to exactly the requested tier (a single `gateway:owner`, `gateway:admin`, or `gateway:viewer` binding) within one transaction. + +**Mechanism.** When the user holds a single binding on the gateway (the normal case under GAM-10), the change SHALL be implemented by updating that `RoleBinding`'s `role_id` **in place**, emitting one `RoleBinding` `UPDATED` watch event -- not delete-then-create -- so there is no window in which the user has no binding. The generic `role_bindings` REST resource remains create/delete-only publicly (`data-model.spec.md`); the in-place role change is an internal mutation performed by the access facade / service layer, and the control plane's Role Bridge handles the `UPDATED` event by reconciling to the client-role union (GAM-02, `openshell-gateway-keycloak.spec.md`). The reconcile is union-based and reads live bindings, so it remains correct even if an implementation instead converges by adjusting individual bindings. Promoting a user to `owner`, or demoting a user from `owner`, SHALL require the caller to be a `gateway:owner` (GAM-08). Demoting an owner SHALL be rejected if that owner is the last remaining owner (GAM-07). Changing to a role the user already holds SHALL be a no-op success (GAM-10). @@ -268,6 +279,8 @@ A gateway SHALL always have at least one `gateway:owner`. The access facade SHAL - Revoking (`DELETE`) a user who is the only remaining owner SHALL be rejected with `409 Conflict`. - Demoting (`PATCH` to `admin` or `user`) a user who is the only remaining owner SHALL be rejected with `409 Conflict`. +Each `409` response SHALL carry a detailed, human-readable error message explaining the constraint -- that a gateway must retain at least one owner and that another owner must be assigned before this one can be demoted or removed -- so the caller (CLI or console) can surface actionable guidance rather than a bare status code. + These rejections SHALL apply regardless of the caller, including an owner acting on themselves. When more than one owner exists, any owner (including the creator) MAY be demoted or revoked by an owner. Ownership is therefore shared and transferable: the creator is not uniquely protected once another owner exists; it is the **last owner** that cannot be removed. The creator (the auto-provisioned first owner) is retained as `is_creator` for display and audit only and SHALL NOT, by itself, confer immutability. @@ -319,17 +332,19 @@ Granting, changing, and revoking access (GAM-04, GAM-05, GAM-06) and searching t The API server SHALL expose `GET /api/hypershell/v1/gateways/{gateway_id}/access/directory?search=` returning candidate users from the configured Keycloak realm for the "Add users" picker. -Each candidate SHALL expose `username`, `name`, and `email`, and SHALL be usable as the target identity of a grant (GAM-04). Candidates SHALL include realm users who have never signed in to HyperShell (the search is against the Keycloak directory, not the HyperShell users inventory). +Each candidate SHALL expose `username`, `name`, and `email`, and SHALL be usable as the target identity of a grant (GAM-04). Candidates SHALL include realm users who have never signed in to HyperShell (the directory is the Keycloak realm, not the HyperShell users inventory). -Because the API server SHALL NOT read the `hypershell-keycloak-admin` Secret (`openshell-gateway-keycloak.spec.md`), the API server SHALL obtain directory results from the control plane over the existing in-cluster gRPC path used for Keycloak-backed provisioning. The control plane SHALL query the Keycloak Admin REST API realm user search and return candidates. Results SHALL be bounded (paginated/capped) and the search SHALL require a non-trivial query term to avoid enumerating the entire realm in one call. +**Backing store.** Rather than issuing a live Keycloak Admin REST query on every keystroke, the directory SHALL be served from a **control-plane-maintained projection of realm users** that the control plane refreshes periodically (and MAY refresh on demand). The control plane -- which holds the `hypershell-keycloak-admin` Secret and already runs reconcile loops -- SHALL periodically list realm users via the Keycloak Admin REST API and persist a directory projection (`username`, `name`, `email`, Keycloak subject) that the API server reads and filters for this endpoint. The API server SHALL NOT read the `hypershell-keycloak-admin` Secret (`openshell-gateway-keycloak.spec.md`). This keeps search fast and resilient to transient Keycloak unavailability; the tradeoff is bounded staleness equal to the refresh interval. Because the projection may be stale, grant (GAM-04) SHALL re-validate the chosen identity against the realm at grant time and reject unknown users (`404`). The refresh interval SHALL be configuration, not code (`specs/standards/`), and newly added realm users become selectable within one refresh cycle. Results SHALL be bounded (paginated/capped); the search SHALL apply the query term server-side against the projection. Authorization SHALL follow GAM-08 (owner or admin on the gateway). -#### Scenario: Directory search returns realm users +#### Scenario: Directory search returns realm users from the projection - GIVEN the realm contains users `dana` and `dale`, neither registered in HyperShell +- AND the control plane has refreshed the directory projection - WHEN an admin of gw-1 calls `GET /api/hypershell/v1/gateways/gw-1/access/directory?search=da` - THEN the response SHALL include candidates for `dana` and `dale` with `username` and `name` +- AND the API server SHALL serve them from the projection without a live Keycloak call #### Scenario: Directory search requires access management authorization @@ -420,9 +435,9 @@ The directory search (GAM-09) is a deliberate exception to the CLI mirror: it se ### Requirement: GAM-14 -- Verification -The API server SHALL include integration tests covering: enriched access list with search and role filter across all three tiers; grant (owner, admin, user) to a registered user and to a never-signed-in directory user (pre-provisioning); atomic role change (promote and demote, including to/from owner) with no zero-access window; revoke; last-owner-protection rejections (`409`) for demoting or revoking the sole owner; owner-tier authorization (a non-owner admin assigning, demoting, or revoking an owner returns `403`); management authorization (viewer `403`, admin allowed for admin/user tiers, non-member `404`); directory-search authorization; and idempotent re-grant. +The API server SHALL include integration tests covering: enriched access list with search and role filter across all three tiers; grant (owner, admin, user) to a registered user and to a never-signed-in directory user (pre-provisioning); grant to an identity absent from the realm rejected with `404` and no pre-provision/binding; atomic role change (promote and demote, including to/from owner) implemented as an in-place `role_id` update (single `UPDATED` event) with no zero-access window; revoke; last-owner-protection rejections (`409`) for demoting or revoking the sole owner, asserting the response carries a descriptive message; owner-tier authorization (a non-owner admin assigning, demoting, or revoking an owner returns `403`); management authorization (viewer `403`, admin allowed for admin/user tiers, non-member `404`); directory-search authorization and serving from the control-plane projection; and idempotent re-grant. -The control plane SHALL include tests covering the `gateway:owner` and `gateway:admin` bridge mappings and reconcile-to-union on create, update, and delete (including demotion stripping `openshell-admin` while retaining `openshell-user`). +The control plane SHALL include tests covering the `gateway:owner` and `gateway:admin` bridge mappings and reconcile-to-union on create, update, and delete (including demotion stripping `openshell-admin` while retaining `openshell-user`), and the periodic directory-projection refresh. The `hsctl` CLI SHALL include tests covering the access commands (GAM-13), including a last-owner-protection failure surfacing as a non-zero exit. From 821ccd766f84db8e99c5a19d8001956fe0be7327 Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Tue, 6 Oct 2026 16:14:03 -0400 Subject: [PATCH 04/23] feat(gateway-access): gateway access management (owner/admin/viewer tiers) Implement the gateway access management specs introduced in this PR: platform GAM-01..14 and web-console GAM-UI-01..11. - API server: new gatewayAccess facade over the existing role_bindings store (enriched server-joined list with search/role filter, grant with Keycloak realm resolution + user pre-provisioning, in-place role change emitting a single UPDATED event, revoke, last-owner 409 protection, owner/admin-tier authorization, idempotent convergence); new gateway:admin built-in role seeded idempotently; RBAC middleware extended for the access resource. - Control plane: OIDC Role Bridge maps gateway:admin to openshell-admin + openshell-user and reconciles to the union of a user's surviving bindings on create/update/delete; Keycloak realm directory projection (ListRealmUsers + refresh loop + a directory gRPC service riding the provisioner channel). - SDK: Go and TypeScript clients for the scoped access resource (generator extended for optional item-GET, PATCH, and a directory sub-collection; service-account output unchanged). - CLI: hsctl create/list/update/delete gatewayAccess (new update verb); 403 and 409 surface as non-zero exits. - Web console: Manage access tab (ports, domain probes, resource table, directory picker, role modal, inline role control, last-owner UI) plus the production API adapter and contract tests. Verified: api-server, control-plane, cli, both SDKs, web-console, and the gateway-management-ui package all build and pass their test and lint gates. Co-Authored-By: Claude Opus 4.8 --- components/api-server/cmd/hypershell/main.go | 1 + .../openapi/openapi.gatewayAccess.yaml | 320 ++++++ components/api-server/openapi/openapi.yaml | 24 + .../provisioner/v1/service_accounts.pb.go | 369 ++++++- .../v1/service_accounts_grpc.pb.go | 150 +++ .../pkg/api/openapi/.openapi-generator/FILES | 18 + .../api-server/pkg/api/openapi/README.md | 14 + .../pkg/api/openapi/api/openapi.yaml | 476 +++++++++ .../api-server/pkg/api/openapi/api_default.go | 955 ++++++++++++++++-- .../pkg/api/openapi/docs/DefaultAPI.md | 367 +++++++ .../openapi/docs/GatewayAccessCapabilities.md | 98 ++ .../docs/GatewayAccessChangeRoleRequest.md | 51 + .../openapi/docs/GatewayAccessGrantRequest.md | 98 ++ .../docs/GatewayAccessGrantResponse.md | 228 +++++ .../pkg/api/openapi/docs/GatewayAccessList.md | 135 +++ .../api/openapi/docs/GatewayAccessListItem.md | 228 +++++ .../pkg/api/openapi/docs/GatewayAccessRole.md | 15 + .../api/openapi/docs/GatewayDirectoryUser.md | 159 +++ .../openapi/docs/GatewayDirectoryUserList.md | 51 + .../model_gateway_access_capabilities.go | 220 ++++ ...odel_gateway_access_change_role_request.go | 156 +++ .../model_gateway_access_grant_request.go | 221 ++++ .../model_gateway_access_grant_response.go | 391 +++++++ .../api/openapi/model_gateway_access_list.go | 268 +++++ .../openapi/model_gateway_access_list_item.go | 391 +++++++ .../api/openapi/model_gateway_access_role.go | 112 ++ .../openapi/model_gateway_directory_user.go | 297 ++++++ .../model_gateway_directory_user_list.go | 156 +++ .../api-server/pkg/rbac/authorization.go | 56 +- .../api-server/plugins/gatewayAccess/dao.go | 148 +++ .../plugins/gatewayAccess/directory.go | 76 ++ .../plugins/gatewayAccess/directory_client.go | 63 ++ .../plugins/gatewayAccess/errors.go | 55 + .../plugins/gatewayAccess/handler.go | 161 +++ .../plugins/gatewayAccess/integration_test.go | 370 +++++++ .../plugins/gatewayAccess/plugin.go | 60 ++ .../plugins/gatewayAccess/presenter.go | 54 + .../plugins/gatewayAccess/service.go | 384 +++++++ .../plugins/gatewayAccess/testmain_test.go | 25 + .../api-server/plugins/roleBindings/dao.go | 23 + .../plugins/roleBindings/service.go | 131 +++ .../api-server/plugins/roles/migration.go | 66 +- components/api-server/plugins/roles/model.go | 1 + components/api-server/plugins/roles/plugin.go | 1 + .../plugins/serviceAccounts/service.go | 6 +- .../provisioner/v1/service_accounts.proto | 34 + components/cli/cmd/hsctl/create/cmd.go | 2 + .../cli/cmd/hsctl/create/gatewayAccess/cmd.go | 71 ++ components/cli/cmd/hsctl/delete/cmd.go | 2 + .../cli/cmd/hsctl/delete/gatewayAccess/cmd.go | 65 ++ components/cli/cmd/hsctl/list/cmd.go | 2 + .../cli/cmd/hsctl/list/gatewayAccess/cmd.go | 80 ++ components/cli/cmd/hsctl/main.go | 2 + components/cli/cmd/hsctl/update/cmd.go | 17 + .../cli/cmd/hsctl/update/gatewayAccess/cmd.go | 66 ++ .../cli/pkg/gatewayaccess/gatewayaccess.go | 91 ++ .../pkg/gatewayaccess/gatewayaccess_test.go | 104 ++ .../cmd/hypershell-controller/main.go | 14 +- .../control-plane/internal/config/config.go | 6 + .../internal/directory/directory.go | 146 +++ .../internal/directory/directory_test.go | 66 ++ .../control-plane/internal/keycloak/client.go | 55 +- .../internal/keycloak/client_test.go | 36 + .../reconciler/role_binding_reconciler.go | 82 +- .../role_binding_reconciler_test.go | 37 +- .../serviceaccountprovisioner/transport.go | 7 +- .../transport_test.go | 4 +- components/sdk-go/client/client.go | 2 +- .../sdk-go/client/gateway_access_api.go | 117 +++ components/sdk-go/client/gateway_api.go | 2 +- .../sdk-go/client/gateway_network_api.go | 2 +- .../sdk-go/client/gateway_release_api.go | 2 +- components/sdk-go/client/iterator.go | 2 +- .../sdk-go/client/managed_cluster_api.go | 2 +- .../open_shell_gateway_service_account_api.go | 2 +- components/sdk-go/client/role_api.go | 2 +- components/sdk-go/client/role_binding_api.go | 2 +- components/sdk-go/client/user_api.go | 2 +- components/sdk-go/types/base.go | 2 +- components/sdk-go/types/gateway.go | 2 +- components/sdk-go/types/gateway_access.go | 131 +++ components/sdk-go/types/gateway_network.go | 2 +- components/sdk-go/types/gateway_release.go | 2 +- components/sdk-go/types/list_options.go | 2 +- components/sdk-go/types/managed_cluster.go | 2 +- .../open_shell_gateway_service_account.go | 2 +- components/sdk-go/types/role.go | 2 +- components/sdk-go/types/role_binding.go | 2 +- components/sdk-go/types/user.go | 2 +- components/sdk-typescript/src/base.ts | 2 +- components/sdk-typescript/src/client.ts | 5 +- components/sdk-typescript/src/gateway.ts | 2 +- .../sdk-typescript/src/gateway_access.ts | 89 ++ .../sdk-typescript/src/gateway_access_api.ts | 80 ++ components/sdk-typescript/src/gateway_api.ts | 2 +- .../sdk-typescript/src/gateway_network.ts | 2 +- .../sdk-typescript/src/gateway_network_api.ts | 2 +- .../sdk-typescript/src/gateway_release.ts | 2 +- .../sdk-typescript/src/gateway_release_api.ts | 2 +- components/sdk-typescript/src/index.ts | 16 +- .../sdk-typescript/src/managed_cluster.ts | 2 +- .../sdk-typescript/src/managed_cluster_api.ts | 2 +- .../src/open_shell_gateway_service_account.ts | 2 +- .../open_shell_gateway_service_account_api.ts | 2 +- components/sdk-typescript/src/role.ts | 2 +- components/sdk-typescript/src/role_api.ts | 2 +- components/sdk-typescript/src/role_binding.ts | 2 +- .../sdk-typescript/src/role_binding_api.ts | 2 +- components/sdk-typescript/src/user.ts | 2 +- components/sdk-typescript/src/user_api.ts | 2 +- .../adapters/api/gateway-operations.test.ts | 154 +++ .../app/adapters/api/gateway-operations.ts | 130 +++ components/web-console/locales/en.json | 182 +++- .../src/access/access-data.ts | 37 + .../src/access/access-page.test.tsx | 324 ++++++ .../src/access/access-page.tsx | 404 ++++++++ .../src/access/access-role.ts | 96 ++ .../src/access/access-row-actions.tsx | 213 ++++ .../src/access/add-users-dialog.tsx | 331 ++++++ .../application/gateway-operations.test.ts | 107 +- .../src/application/gateway-operations.ts | 20 + .../src/application/gateway-probes.ts | 7 +- .../src/application/gateway-types.ts | 115 +++ .../src/gateway-ui-provider.test.tsx | 5 + .../src/gateways/gateway-create.test.tsx | 5 + packages/gateway-management-ui/src/index.ts | 11 + .../gateway-management-ui/src/messages.ts | 234 ++++- .../src/pages/gateway-pages.test.tsx | 5 + .../src/pages/gateway-pages.tsx | 19 +- .../service-accounts-page.test.tsx | 5 + scripts/sdk-generator/model.go | 11 + scripts/sdk-generator/parser.go | 112 +- .../templates/go/scoped_client.go.tmpl | 35 +- .../templates/ts/scoped_client.ts.tmpl | 31 +- skills/RECONCILE.md | 24 +- 135 files changed, 11509 insertions(+), 258 deletions(-) create mode 100644 components/api-server/openapi/openapi.gatewayAccess.yaml create mode 100644 components/api-server/pkg/api/openapi/docs/GatewayAccessCapabilities.md create mode 100644 components/api-server/pkg/api/openapi/docs/GatewayAccessChangeRoleRequest.md create mode 100644 components/api-server/pkg/api/openapi/docs/GatewayAccessGrantRequest.md create mode 100644 components/api-server/pkg/api/openapi/docs/GatewayAccessGrantResponse.md create mode 100644 components/api-server/pkg/api/openapi/docs/GatewayAccessList.md create mode 100644 components/api-server/pkg/api/openapi/docs/GatewayAccessListItem.md create mode 100644 components/api-server/pkg/api/openapi/docs/GatewayAccessRole.md create mode 100644 components/api-server/pkg/api/openapi/docs/GatewayDirectoryUser.md create mode 100644 components/api-server/pkg/api/openapi/docs/GatewayDirectoryUserList.md create mode 100644 components/api-server/pkg/api/openapi/model_gateway_access_capabilities.go create mode 100644 components/api-server/pkg/api/openapi/model_gateway_access_change_role_request.go create mode 100644 components/api-server/pkg/api/openapi/model_gateway_access_grant_request.go create mode 100644 components/api-server/pkg/api/openapi/model_gateway_access_grant_response.go create mode 100644 components/api-server/pkg/api/openapi/model_gateway_access_list.go create mode 100644 components/api-server/pkg/api/openapi/model_gateway_access_list_item.go create mode 100644 components/api-server/pkg/api/openapi/model_gateway_access_role.go create mode 100644 components/api-server/pkg/api/openapi/model_gateway_directory_user.go create mode 100644 components/api-server/pkg/api/openapi/model_gateway_directory_user_list.go create mode 100644 components/api-server/plugins/gatewayAccess/dao.go create mode 100644 components/api-server/plugins/gatewayAccess/directory.go create mode 100644 components/api-server/plugins/gatewayAccess/directory_client.go create mode 100644 components/api-server/plugins/gatewayAccess/errors.go create mode 100644 components/api-server/plugins/gatewayAccess/handler.go create mode 100644 components/api-server/plugins/gatewayAccess/integration_test.go create mode 100644 components/api-server/plugins/gatewayAccess/plugin.go create mode 100644 components/api-server/plugins/gatewayAccess/presenter.go create mode 100644 components/api-server/plugins/gatewayAccess/service.go create mode 100644 components/api-server/plugins/gatewayAccess/testmain_test.go create mode 100644 components/cli/cmd/hsctl/create/gatewayAccess/cmd.go create mode 100644 components/cli/cmd/hsctl/delete/gatewayAccess/cmd.go create mode 100644 components/cli/cmd/hsctl/list/gatewayAccess/cmd.go create mode 100644 components/cli/cmd/hsctl/update/cmd.go create mode 100644 components/cli/cmd/hsctl/update/gatewayAccess/cmd.go create mode 100644 components/cli/pkg/gatewayaccess/gatewayaccess.go create mode 100644 components/cli/pkg/gatewayaccess/gatewayaccess_test.go create mode 100644 components/control-plane/internal/directory/directory.go create mode 100644 components/control-plane/internal/directory/directory_test.go create mode 100644 components/sdk-go/client/gateway_access_api.go create mode 100644 components/sdk-go/types/gateway_access.go create mode 100644 components/sdk-typescript/src/gateway_access.ts create mode 100644 components/sdk-typescript/src/gateway_access_api.ts create mode 100644 packages/gateway-management-ui/src/access/access-data.ts create mode 100644 packages/gateway-management-ui/src/access/access-page.test.tsx create mode 100644 packages/gateway-management-ui/src/access/access-page.tsx create mode 100644 packages/gateway-management-ui/src/access/access-role.ts create mode 100644 packages/gateway-management-ui/src/access/access-row-actions.tsx create mode 100644 packages/gateway-management-ui/src/access/add-users-dialog.tsx diff --git a/components/api-server/cmd/hypershell/main.go b/components/api-server/cmd/hypershell/main.go index 3db09eeaa..f613f7616 100644 --- a/components/api-server/cmd/hypershell/main.go +++ b/components/api-server/cmd/hypershell/main.go @@ -12,6 +12,7 @@ import ( pkgcmd "github.com/openshift-online/rh-trex-ai/components/api-server/pkg/cmd" _ "github.com/openshift-online/hypershell/components/api-server/cmd/hypershell/environments" + _ "github.com/openshift-online/hypershell/components/api-server/plugins/gatewayAccess" _ "github.com/openshift-online/hypershell/components/api-server/plugins/gatewayNetworks" _ "github.com/openshift-online/hypershell/components/api-server/plugins/gatewayReleases" _ "github.com/openshift-online/hypershell/components/api-server/plugins/gateways" diff --git a/components/api-server/openapi/openapi.gatewayAccess.yaml b/components/api-server/openapi/openapi.gatewayAccess.yaml new file mode 100644 index 000000000..b59ab953e --- /dev/null +++ b/components/api-server/openapi/openapi.gatewayAccess.yaml @@ -0,0 +1,320 @@ +paths: + /api/hypershell/v1/gateways/{gateway_id}/access: + parameters: + - $ref: '#/components/parameters/gatewayId' + get: + operationId: listGatewayAccess + summary: List access grants on a gateway + parameters: + - $ref: '#/components/parameters/page' + - $ref: '#/components/parameters/size' + - $ref: '#/components/parameters/role' + - $ref: '#/components/parameters/search' + - $ref: '#/components/parameters/sort' + - $ref: '#/components/parameters/order' + responses: + '200': + description: Access grants and caller capabilities + content: + application/json: + schema: + $ref: '#/components/schemas/GatewayAccessList' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: 'openapi.yaml#/components/responses/UnauthorizedError' + '404': + $ref: '#/components/responses/NotFoundError' + '500': + $ref: 'openapi.yaml#/components/responses/InternalServerError' + post: + operationId: grantGatewayAccess + summary: Grant a user access to a gateway + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GatewayAccessGrantRequest' + responses: + '201': + description: Access granted + content: + application/json: + schema: + $ref: '#/components/schemas/GatewayAccessGrantResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: 'openapi.yaml#/components/responses/UnauthorizedError' + '403': + $ref: 'openapi.yaml#/components/responses/ForbiddenError' + '404': + $ref: '#/components/responses/NotFoundError' + '409': + $ref: '#/components/responses/ConflictError' + '503': + $ref: '#/components/responses/ServiceUnavailableError' + /api/hypershell/v1/gateways/{gateway_id}/access/directory: + parameters: + - $ref: '#/components/parameters/gatewayId' + get: + operationId: searchGatewayDirectory + summary: Search the Keycloak realm directory for candidate users + parameters: + - $ref: '#/components/parameters/search' + responses: + '200': + description: Candidate realm users + content: + application/json: + schema: + $ref: '#/components/schemas/GatewayDirectoryUserList' + '401': + $ref: 'openapi.yaml#/components/responses/UnauthorizedError' + '403': + $ref: 'openapi.yaml#/components/responses/ForbiddenError' + '404': + $ref: '#/components/responses/NotFoundError' + '503': + $ref: '#/components/responses/ServiceUnavailableError' + /api/hypershell/v1/gateways/{gateway_id}/access/{user_id}: + parameters: + - $ref: '#/components/parameters/gatewayId' + - $ref: '#/components/parameters/userId' + patch: + operationId: changeGatewayAccessRole + summary: Change a user's access role on a gateway + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GatewayAccessChangeRoleRequest' + responses: + '200': + description: Role changed + content: + application/json: + schema: + $ref: '#/components/schemas/GatewayAccessGrantResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: 'openapi.yaml#/components/responses/UnauthorizedError' + '403': + $ref: 'openapi.yaml#/components/responses/ForbiddenError' + '404': + $ref: '#/components/responses/NotFoundError' + '409': + $ref: '#/components/responses/ConflictError' + delete: + operationId: revokeGatewayAccess + summary: Revoke a user's access to a gateway + responses: + '204': + description: Access revoked + '401': + $ref: 'openapi.yaml#/components/responses/UnauthorizedError' + '403': + $ref: 'openapi.yaml#/components/responses/ForbiddenError' + '404': + $ref: '#/components/responses/NotFoundError' + '409': + $ref: '#/components/responses/ConflictError' + +components: + parameters: + gatewayId: + name: gateway_id + in: path + required: true + description: Selected Gateway ID + schema: + type: string + userId: + name: user_id + in: path + required: true + description: HyperShell User ID + schema: + type: string + page: + name: page + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + size: + name: size + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + role: + name: role + in: query + required: false + description: Filter by console role tier + schema: + $ref: '#/components/schemas/GatewayAccessRole' + search: + name: search + in: query + required: false + description: Case-insensitive literal substring over username and name + schema: + type: string + sort: + name: sort + in: query + required: false + schema: + type: string + enum: [username, name, role, granted_at] + default: granted_at + order: + name: order + in: query + required: false + schema: + type: string + enum: [asc, desc] + default: desc + + responses: + BadRequestError: + description: Invalid request or query value + content: + application/json: + schema: + $ref: 'openapi.yaml#/components/schemas/Error' + NotFoundError: + description: Gateway or user was not found or is not visible + content: + application/json: + schema: + $ref: 'openapi.yaml#/components/schemas/Error' + ConflictError: + description: The operation would remove the last owner (last-owner protection) + content: + application/json: + schema: + $ref: 'openapi.yaml#/components/schemas/Error' + ServiceUnavailableError: + description: The Keycloak directory projection is unavailable + content: + application/json: + schema: + $ref: 'openapi.yaml#/components/schemas/Error' + + schemas: + GatewayAccessRole: + type: string + enum: [owner, admin, user] + GatewayAccessGrantRequest: + type: object + additionalProperties: false + required: [username, role] + properties: + username: + type: string + minLength: 1 + subject: + type: string + description: Optional Keycloak subject reference returned by the directory search + role: + $ref: '#/components/schemas/GatewayAccessRole' + GatewayAccessChangeRoleRequest: + type: object + additionalProperties: false + required: [role] + properties: + role: + $ref: '#/components/schemas/GatewayAccessRole' + GatewayAccessListItem: + type: object + required: + - role_binding_id + - user_id + - username + - role + - is_creator + - granted_at + properties: + role_binding_id: + type: string + user_id: + type: string + username: + type: string + name: + type: string + nullable: true + email: + type: string + nullable: true + role: + $ref: '#/components/schemas/GatewayAccessRole' + is_creator: + type: boolean + granted_at: + type: string + format: date-time + GatewayAccessGrantResponse: + allOf: + - $ref: '#/components/schemas/GatewayAccessListItem' + GatewayAccessCapabilities: + type: object + required: [can_manage_access, can_manage_owners] + properties: + caller_role: + $ref: '#/components/schemas/GatewayAccessRole' + can_manage_access: + type: boolean + can_manage_owners: + type: boolean + GatewayAccessList: + type: object + required: [page, size, total, capabilities, items] + properties: + page: + type: integer + size: + type: integer + total: + type: integer + capabilities: + $ref: '#/components/schemas/GatewayAccessCapabilities' + items: + type: array + items: + $ref: '#/components/schemas/GatewayAccessListItem' + GatewayDirectoryUser: + type: object + required: [username] + properties: + username: + type: string + name: + type: string + nullable: true + email: + type: string + nullable: true + subject: + type: string + nullable: true + GatewayDirectoryUserList: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: '#/components/schemas/GatewayDirectoryUser' diff --git a/components/api-server/openapi/openapi.yaml b/components/api-server/openapi/openapi.yaml index bc7fe4612..0e4a0d55a 100644 --- a/components/api-server/openapi/openapi.yaml +++ b/components/api-server/openapi/openapi.yaml @@ -62,6 +62,12 @@ paths: $ref: 'openapi.users.yaml#/paths/~1api~1hypershell~1v1~1users' /api/hypershell/v1/users/{id}: $ref: 'openapi.users.yaml#/paths/~1api~1hypershell~1v1~1users~1{id}' + /api/hypershell/v1/gateways/{gateway_id}/access: + $ref: 'openapi.gatewayAccess.yaml#/paths/~1api~1hypershell~1v1~1gateways~1{gateway_id}~1access' + /api/hypershell/v1/gateways/{gateway_id}/access/directory: + $ref: 'openapi.gatewayAccess.yaml#/paths/~1api~1hypershell~1v1~1gateways~1{gateway_id}~1access~1directory' + /api/hypershell/v1/gateways/{gateway_id}/access/{user_id}: + $ref: 'openapi.gatewayAccess.yaml#/paths/~1api~1hypershell~1v1~1gateways~1{gateway_id}~1access~1{user_id}' # AUTO-ADD NEW PATHS components: schemas: @@ -166,6 +172,24 @@ components: $ref: 'openapi.users.yaml#/components/schemas/User' UserList: $ref: 'openapi.users.yaml#/components/schemas/UserList' + GatewayAccessRole: + $ref: 'openapi.gatewayAccess.yaml#/components/schemas/GatewayAccessRole' + GatewayAccessGrantRequest: + $ref: 'openapi.gatewayAccess.yaml#/components/schemas/GatewayAccessGrantRequest' + GatewayAccessChangeRoleRequest: + $ref: 'openapi.gatewayAccess.yaml#/components/schemas/GatewayAccessChangeRoleRequest' + GatewayAccessListItem: + $ref: 'openapi.gatewayAccess.yaml#/components/schemas/GatewayAccessListItem' + GatewayAccessGrantResponse: + $ref: 'openapi.gatewayAccess.yaml#/components/schemas/GatewayAccessGrantResponse' + GatewayAccessCapabilities: + $ref: 'openapi.gatewayAccess.yaml#/components/schemas/GatewayAccessCapabilities' + GatewayAccessList: + $ref: 'openapi.gatewayAccess.yaml#/components/schemas/GatewayAccessList' + GatewayDirectoryUser: + $ref: 'openapi.gatewayAccess.yaml#/components/schemas/GatewayDirectoryUser' + GatewayDirectoryUserList: + $ref: 'openapi.gatewayAccess.yaml#/components/schemas/GatewayDirectoryUserList' # AUTO-ADD NEW SCHEMAS parameters: id: diff --git a/components/api-server/pkg/api/grpc/hypershell/provisioner/v1/service_accounts.pb.go b/components/api-server/pkg/api/grpc/hypershell/provisioner/v1/service_accounts.pb.go index 901151fbf..750f257c4 100644 --- a/components/api-server/pkg/api/grpc/hypershell/provisioner/v1/service_accounts.pb.go +++ b/components/api-server/pkg/api/grpc/hypershell/provisioner/v1/service_accounts.pb.go @@ -861,6 +861,266 @@ func (x *ListManagedResponse) GetClients() []*ManagedClient { return nil } +type DirectoryUser struct { + state protoimpl.MessageState `protogen:"open.v1"` + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"` + Subject string `protobuf:"bytes,4,opt,name=subject,proto3" json:"subject,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DirectoryUser) Reset() { + *x = DirectoryUser{} + mi := &file_hypershell_provisioner_v1_service_accounts_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DirectoryUser) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DirectoryUser) ProtoMessage() {} + +func (x *DirectoryUser) ProtoReflect() protoreflect.Message { + mi := &file_hypershell_provisioner_v1_service_accounts_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DirectoryUser.ProtoReflect.Descriptor instead. +func (*DirectoryUser) Descriptor() ([]byte, []int) { + return file_hypershell_provisioner_v1_service_accounts_proto_rawDescGZIP(), []int{16} +} + +func (x *DirectoryUser) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *DirectoryUser) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *DirectoryUser) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *DirectoryUser) GetSubject() string { + if x != nil { + return x.Subject + } + return "" +} + +type SearchDirectoryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Query string `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"` + Limit int32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SearchDirectoryRequest) Reset() { + *x = SearchDirectoryRequest{} + mi := &file_hypershell_provisioner_v1_service_accounts_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SearchDirectoryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SearchDirectoryRequest) ProtoMessage() {} + +func (x *SearchDirectoryRequest) ProtoReflect() protoreflect.Message { + mi := &file_hypershell_provisioner_v1_service_accounts_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SearchDirectoryRequest.ProtoReflect.Descriptor instead. +func (*SearchDirectoryRequest) Descriptor() ([]byte, []int) { + return file_hypershell_provisioner_v1_service_accounts_proto_rawDescGZIP(), []int{17} +} + +func (x *SearchDirectoryRequest) GetQuery() string { + if x != nil { + return x.Query + } + return "" +} + +func (x *SearchDirectoryRequest) GetLimit() int32 { + if x != nil { + return x.Limit + } + return 0 +} + +type SearchDirectoryResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Users []*DirectoryUser `protobuf:"bytes,1,rep,name=users,proto3" json:"users,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SearchDirectoryResponse) Reset() { + *x = SearchDirectoryResponse{} + mi := &file_hypershell_provisioner_v1_service_accounts_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SearchDirectoryResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SearchDirectoryResponse) ProtoMessage() {} + +func (x *SearchDirectoryResponse) ProtoReflect() protoreflect.Message { + mi := &file_hypershell_provisioner_v1_service_accounts_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SearchDirectoryResponse.ProtoReflect.Descriptor instead. +func (*SearchDirectoryResponse) Descriptor() ([]byte, []int) { + return file_hypershell_provisioner_v1_service_accounts_proto_rawDescGZIP(), []int{18} +} + +func (x *SearchDirectoryResponse) GetUsers() []*DirectoryUser { + if x != nil { + return x.Users + } + return nil +} + +type ResolveUserRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResolveUserRequest) Reset() { + *x = ResolveUserRequest{} + mi := &file_hypershell_provisioner_v1_service_accounts_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResolveUserRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResolveUserRequest) ProtoMessage() {} + +func (x *ResolveUserRequest) ProtoReflect() protoreflect.Message { + mi := &file_hypershell_provisioner_v1_service_accounts_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResolveUserRequest.ProtoReflect.Descriptor instead. +func (*ResolveUserRequest) Descriptor() ([]byte, []int) { + return file_hypershell_provisioner_v1_service_accounts_proto_rawDescGZIP(), []int{19} +} + +func (x *ResolveUserRequest) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +type ResolveUserResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + User *DirectoryUser `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` + Found bool `protobuf:"varint,2,opt,name=found,proto3" json:"found,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResolveUserResponse) Reset() { + *x = ResolveUserResponse{} + mi := &file_hypershell_provisioner_v1_service_accounts_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResolveUserResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResolveUserResponse) ProtoMessage() {} + +func (x *ResolveUserResponse) ProtoReflect() protoreflect.Message { + mi := &file_hypershell_provisioner_v1_service_accounts_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResolveUserResponse.ProtoReflect.Descriptor instead. +func (*ResolveUserResponse) Descriptor() ([]byte, []int) { + return file_hypershell_provisioner_v1_service_accounts_proto_rawDescGZIP(), []int{20} +} + +func (x *ResolveUserResponse) GetUser() *DirectoryUser { + if x != nil { + return x.User + } + return nil +} + +func (x *ResolveUserResponse) GetFound() bool { + if x != nil { + return x.Found + } + return false +} + var File_hypershell_provisioner_v1_service_accounts_proto protoreflect.FileDescriptor const file_hypershell_provisioner_v1_service_accounts_proto_rawDesc = "" + @@ -925,7 +1185,22 @@ const file_hypershell_provisioner_v1_service_accounts_proto_rawDesc = "" + "gateway_id\x18\x03 \x01(\tR\tgatewayId\x12,\n" + "\x12service_account_id\x18\x04 \x01(\tR\x10serviceAccountId\"Y\n" + "\x13ListManagedResponse\x12B\n" + - "\aclients\x18\x01 \x03(\v2(.hypershell.provisioner.v1.ManagedClientR\aclients2\x99\x06\n" + + "\aclients\x18\x01 \x03(\v2(.hypershell.provisioner.v1.ManagedClientR\aclients\"o\n" + + "\rDirectoryUser\x12\x1a\n" + + "\busername\x18\x01 \x01(\tR\busername\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" + + "\x05email\x18\x03 \x01(\tR\x05email\x12\x18\n" + + "\asubject\x18\x04 \x01(\tR\asubject\"D\n" + + "\x16SearchDirectoryRequest\x12\x14\n" + + "\x05query\x18\x01 \x01(\tR\x05query\x12\x14\n" + + "\x05limit\x18\x02 \x01(\x05R\x05limit\"Y\n" + + "\x17SearchDirectoryResponse\x12>\n" + + "\x05users\x18\x01 \x03(\v2(.hypershell.provisioner.v1.DirectoryUserR\x05users\"0\n" + + "\x12ResolveUserRequest\x12\x1a\n" + + "\busername\x18\x01 \x01(\tR\busername\"i\n" + + "\x13ResolveUserResponse\x12<\n" + + "\x04user\x18\x01 \x01(\v2(.hypershell.provisioner.v1.DirectoryUserR\x04user\x12\x14\n" + + "\x05found\x18\x02 \x01(\bR\x05found2\x99\x06\n" + "0OpenShellGatewayServiceAccountProvisionerService\x12f\n" + "\tProvision\x12+.hypershell.provisioner.v1.ProvisionRequest\x1a,.hypershell.provisioner.v1.ProvisionResponse\x12f\n" + "\tReconcile\x12+.hypershell.provisioner.v1.ReconcileRequest\x1a,.hypershell.provisioner.v1.ReconcileResponse\x12`\n" + @@ -933,7 +1208,10 @@ const file_hypershell_provisioner_v1_service_accounts_proto_rawDesc = "" + "\x06Delete\x12(.hypershell.provisioner.v1.DeleteRequest\x1a).hypershell.provisioner.v1.DeleteResponse\x12r\n" + "\rDeleteManaged\x12/.hypershell.provisioner.v1.DeleteManagedRequest\x1a0.hypershell.provisioner.v1.DeleteManagedResponse\x12r\n" + "\rDeleteGateway\x12/.hypershell.provisioner.v1.DeleteGatewayRequest\x1a0.hypershell.provisioner.v1.DeleteGatewayResponse\x12l\n" + - "\vListManaged\x12-.hypershell.provisioner.v1.ListManagedRequest\x1a..hypershell.provisioner.v1.ListManagedResponseBtZrgithub.com/openshift-online/hypershell/components/api-server/pkg/api/grpc/hypershell/provisioner/v1;provisioner_v1b\x06proto3" + "\vListManaged\x12-.hypershell.provisioner.v1.ListManagedRequest\x1a..hypershell.provisioner.v1.ListManagedResponse2\xfa\x01\n" + + "\x10DirectoryService\x12x\n" + + "\x0fSearchDirectory\x121.hypershell.provisioner.v1.SearchDirectoryRequest\x1a2.hypershell.provisioner.v1.SearchDirectoryResponse\x12l\n" + + "\vResolveUser\x12-.hypershell.provisioner.v1.ResolveUserRequest\x1a..hypershell.provisioner.v1.ResolveUserResponseBtZrgithub.com/openshift-online/hypershell/components/api-server/pkg/api/grpc/hypershell/provisioner/v1;provisioner_v1b\x06proto3" var ( file_hypershell_provisioner_v1_service_accounts_proto_rawDescOnce sync.Once @@ -947,48 +1225,59 @@ func file_hypershell_provisioner_v1_service_accounts_proto_rawDescGZIP() []byte return file_hypershell_provisioner_v1_service_accounts_proto_rawDescData } -var file_hypershell_provisioner_v1_service_accounts_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_hypershell_provisioner_v1_service_accounts_proto_msgTypes = make([]protoimpl.MessageInfo, 21) var file_hypershell_provisioner_v1_service_accounts_proto_goTypes = []any{ - (*ServiceAccountSpec)(nil), // 0: hypershell.provisioner.v1.ServiceAccountSpec - (*ProvisionRequest)(nil), // 1: hypershell.provisioner.v1.ProvisionRequest - (*ProvisionResponse)(nil), // 2: hypershell.provisioner.v1.ProvisionResponse - (*ReconcileRequest)(nil), // 3: hypershell.provisioner.v1.ReconcileRequest - (*ReconcileResponse)(nil), // 4: hypershell.provisioner.v1.ReconcileResponse - (*DisableRequest)(nil), // 5: hypershell.provisioner.v1.DisableRequest - (*DisableResponse)(nil), // 6: hypershell.provisioner.v1.DisableResponse - (*DeleteRequest)(nil), // 7: hypershell.provisioner.v1.DeleteRequest - (*DeleteResponse)(nil), // 8: hypershell.provisioner.v1.DeleteResponse - (*DeleteManagedRequest)(nil), // 9: hypershell.provisioner.v1.DeleteManagedRequest - (*DeleteManagedResponse)(nil), // 10: hypershell.provisioner.v1.DeleteManagedResponse - (*DeleteGatewayRequest)(nil), // 11: hypershell.provisioner.v1.DeleteGatewayRequest - (*DeleteGatewayResponse)(nil), // 12: hypershell.provisioner.v1.DeleteGatewayResponse - (*ListManagedRequest)(nil), // 13: hypershell.provisioner.v1.ListManagedRequest - (*ManagedClient)(nil), // 14: hypershell.provisioner.v1.ManagedClient - (*ListManagedResponse)(nil), // 15: hypershell.provisioner.v1.ListManagedResponse + (*ServiceAccountSpec)(nil), // 0: hypershell.provisioner.v1.ServiceAccountSpec + (*ProvisionRequest)(nil), // 1: hypershell.provisioner.v1.ProvisionRequest + (*ProvisionResponse)(nil), // 2: hypershell.provisioner.v1.ProvisionResponse + (*ReconcileRequest)(nil), // 3: hypershell.provisioner.v1.ReconcileRequest + (*ReconcileResponse)(nil), // 4: hypershell.provisioner.v1.ReconcileResponse + (*DisableRequest)(nil), // 5: hypershell.provisioner.v1.DisableRequest + (*DisableResponse)(nil), // 6: hypershell.provisioner.v1.DisableResponse + (*DeleteRequest)(nil), // 7: hypershell.provisioner.v1.DeleteRequest + (*DeleteResponse)(nil), // 8: hypershell.provisioner.v1.DeleteResponse + (*DeleteManagedRequest)(nil), // 9: hypershell.provisioner.v1.DeleteManagedRequest + (*DeleteManagedResponse)(nil), // 10: hypershell.provisioner.v1.DeleteManagedResponse + (*DeleteGatewayRequest)(nil), // 11: hypershell.provisioner.v1.DeleteGatewayRequest + (*DeleteGatewayResponse)(nil), // 12: hypershell.provisioner.v1.DeleteGatewayResponse + (*ListManagedRequest)(nil), // 13: hypershell.provisioner.v1.ListManagedRequest + (*ManagedClient)(nil), // 14: hypershell.provisioner.v1.ManagedClient + (*ListManagedResponse)(nil), // 15: hypershell.provisioner.v1.ListManagedResponse + (*DirectoryUser)(nil), // 16: hypershell.provisioner.v1.DirectoryUser + (*SearchDirectoryRequest)(nil), // 17: hypershell.provisioner.v1.SearchDirectoryRequest + (*SearchDirectoryResponse)(nil), // 18: hypershell.provisioner.v1.SearchDirectoryResponse + (*ResolveUserRequest)(nil), // 19: hypershell.provisioner.v1.ResolveUserRequest + (*ResolveUserResponse)(nil), // 20: hypershell.provisioner.v1.ResolveUserResponse } var file_hypershell_provisioner_v1_service_accounts_proto_depIdxs = []int32{ 0, // 0: hypershell.provisioner.v1.ProvisionRequest.spec:type_name -> hypershell.provisioner.v1.ServiceAccountSpec 0, // 1: hypershell.provisioner.v1.ReconcileRequest.spec:type_name -> hypershell.provisioner.v1.ServiceAccountSpec 14, // 2: hypershell.provisioner.v1.ListManagedResponse.clients:type_name -> hypershell.provisioner.v1.ManagedClient - 1, // 3: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.Provision:input_type -> hypershell.provisioner.v1.ProvisionRequest - 3, // 4: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.Reconcile:input_type -> hypershell.provisioner.v1.ReconcileRequest - 5, // 5: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.Disable:input_type -> hypershell.provisioner.v1.DisableRequest - 7, // 6: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.Delete:input_type -> hypershell.provisioner.v1.DeleteRequest - 9, // 7: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.DeleteManaged:input_type -> hypershell.provisioner.v1.DeleteManagedRequest - 11, // 8: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.DeleteGateway:input_type -> hypershell.provisioner.v1.DeleteGatewayRequest - 13, // 9: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.ListManaged:input_type -> hypershell.provisioner.v1.ListManagedRequest - 2, // 10: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.Provision:output_type -> hypershell.provisioner.v1.ProvisionResponse - 4, // 11: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.Reconcile:output_type -> hypershell.provisioner.v1.ReconcileResponse - 6, // 12: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.Disable:output_type -> hypershell.provisioner.v1.DisableResponse - 8, // 13: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.Delete:output_type -> hypershell.provisioner.v1.DeleteResponse - 10, // 14: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.DeleteManaged:output_type -> hypershell.provisioner.v1.DeleteManagedResponse - 12, // 15: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.DeleteGateway:output_type -> hypershell.provisioner.v1.DeleteGatewayResponse - 15, // 16: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.ListManaged:output_type -> hypershell.provisioner.v1.ListManagedResponse - 10, // [10:17] is the sub-list for method output_type - 3, // [3:10] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 16, // 3: hypershell.provisioner.v1.SearchDirectoryResponse.users:type_name -> hypershell.provisioner.v1.DirectoryUser + 16, // 4: hypershell.provisioner.v1.ResolveUserResponse.user:type_name -> hypershell.provisioner.v1.DirectoryUser + 1, // 5: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.Provision:input_type -> hypershell.provisioner.v1.ProvisionRequest + 3, // 6: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.Reconcile:input_type -> hypershell.provisioner.v1.ReconcileRequest + 5, // 7: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.Disable:input_type -> hypershell.provisioner.v1.DisableRequest + 7, // 8: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.Delete:input_type -> hypershell.provisioner.v1.DeleteRequest + 9, // 9: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.DeleteManaged:input_type -> hypershell.provisioner.v1.DeleteManagedRequest + 11, // 10: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.DeleteGateway:input_type -> hypershell.provisioner.v1.DeleteGatewayRequest + 13, // 11: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.ListManaged:input_type -> hypershell.provisioner.v1.ListManagedRequest + 17, // 12: hypershell.provisioner.v1.DirectoryService.SearchDirectory:input_type -> hypershell.provisioner.v1.SearchDirectoryRequest + 19, // 13: hypershell.provisioner.v1.DirectoryService.ResolveUser:input_type -> hypershell.provisioner.v1.ResolveUserRequest + 2, // 14: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.Provision:output_type -> hypershell.provisioner.v1.ProvisionResponse + 4, // 15: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.Reconcile:output_type -> hypershell.provisioner.v1.ReconcileResponse + 6, // 16: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.Disable:output_type -> hypershell.provisioner.v1.DisableResponse + 8, // 17: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.Delete:output_type -> hypershell.provisioner.v1.DeleteResponse + 10, // 18: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.DeleteManaged:output_type -> hypershell.provisioner.v1.DeleteManagedResponse + 12, // 19: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.DeleteGateway:output_type -> hypershell.provisioner.v1.DeleteGatewayResponse + 15, // 20: hypershell.provisioner.v1.OpenShellGatewayServiceAccountProvisionerService.ListManaged:output_type -> hypershell.provisioner.v1.ListManagedResponse + 18, // 21: hypershell.provisioner.v1.DirectoryService.SearchDirectory:output_type -> hypershell.provisioner.v1.SearchDirectoryResponse + 20, // 22: hypershell.provisioner.v1.DirectoryService.ResolveUser:output_type -> hypershell.provisioner.v1.ResolveUserResponse + 14, // [14:23] is the sub-list for method output_type + 5, // [5:14] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name } func init() { file_hypershell_provisioner_v1_service_accounts_proto_init() } @@ -1002,9 +1291,9 @@ func file_hypershell_provisioner_v1_service_accounts_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_hypershell_provisioner_v1_service_accounts_proto_rawDesc), len(file_hypershell_provisioner_v1_service_accounts_proto_rawDesc)), NumEnums: 0, - NumMessages: 16, + NumMessages: 21, NumExtensions: 0, - NumServices: 1, + NumServices: 2, }, GoTypes: file_hypershell_provisioner_v1_service_accounts_proto_goTypes, DependencyIndexes: file_hypershell_provisioner_v1_service_accounts_proto_depIdxs, diff --git a/components/api-server/pkg/api/grpc/hypershell/provisioner/v1/service_accounts_grpc.pb.go b/components/api-server/pkg/api/grpc/hypershell/provisioner/v1/service_accounts_grpc.pb.go index acaa086aa..47202fbb3 100644 --- a/components/api-server/pkg/api/grpc/hypershell/provisioner/v1/service_accounts_grpc.pb.go +++ b/components/api-server/pkg/api/grpc/hypershell/provisioner/v1/service_accounts_grpc.pb.go @@ -356,3 +356,153 @@ var OpenShellGatewayServiceAccountProvisionerService_ServiceDesc = grpc.ServiceD Streams: []grpc.StreamDesc{}, Metadata: "hypershell/provisioner/v1/service_accounts.proto", } + +const ( + DirectoryService_SearchDirectory_FullMethodName = "/hypershell.provisioner.v1.DirectoryService/SearchDirectory" + DirectoryService_ResolveUser_FullMethodName = "/hypershell.provisioner.v1.DirectoryService/ResolveUser" +) + +// DirectoryServiceClient is the client API for DirectoryService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// DirectoryService serves the control-plane-maintained projection of Keycloak +// realm users (GAM-09) to the API server over the same private in-cluster +// channel as the provisioner. The API server never reads the Keycloak admin +// Secret; it reads this projection. +type DirectoryServiceClient interface { + SearchDirectory(ctx context.Context, in *SearchDirectoryRequest, opts ...grpc.CallOption) (*SearchDirectoryResponse, error) + ResolveUser(ctx context.Context, in *ResolveUserRequest, opts ...grpc.CallOption) (*ResolveUserResponse, error) +} + +type directoryServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewDirectoryServiceClient(cc grpc.ClientConnInterface) DirectoryServiceClient { + return &directoryServiceClient{cc} +} + +func (c *directoryServiceClient) SearchDirectory(ctx context.Context, in *SearchDirectoryRequest, opts ...grpc.CallOption) (*SearchDirectoryResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SearchDirectoryResponse) + err := c.cc.Invoke(ctx, DirectoryService_SearchDirectory_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *directoryServiceClient) ResolveUser(ctx context.Context, in *ResolveUserRequest, opts ...grpc.CallOption) (*ResolveUserResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResolveUserResponse) + err := c.cc.Invoke(ctx, DirectoryService_ResolveUser_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// DirectoryServiceServer is the server API for DirectoryService service. +// All implementations must embed UnimplementedDirectoryServiceServer +// for forward compatibility. +// +// DirectoryService serves the control-plane-maintained projection of Keycloak +// realm users (GAM-09) to the API server over the same private in-cluster +// channel as the provisioner. The API server never reads the Keycloak admin +// Secret; it reads this projection. +type DirectoryServiceServer interface { + SearchDirectory(context.Context, *SearchDirectoryRequest) (*SearchDirectoryResponse, error) + ResolveUser(context.Context, *ResolveUserRequest) (*ResolveUserResponse, error) + mustEmbedUnimplementedDirectoryServiceServer() +} + +// UnimplementedDirectoryServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedDirectoryServiceServer struct{} + +func (UnimplementedDirectoryServiceServer) SearchDirectory(context.Context, *SearchDirectoryRequest) (*SearchDirectoryResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SearchDirectory not implemented") +} +func (UnimplementedDirectoryServiceServer) ResolveUser(context.Context, *ResolveUserRequest) (*ResolveUserResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ResolveUser not implemented") +} +func (UnimplementedDirectoryServiceServer) mustEmbedUnimplementedDirectoryServiceServer() {} +func (UnimplementedDirectoryServiceServer) testEmbeddedByValue() {} + +// UnsafeDirectoryServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to DirectoryServiceServer will +// result in compilation errors. +type UnsafeDirectoryServiceServer interface { + mustEmbedUnimplementedDirectoryServiceServer() +} + +func RegisterDirectoryServiceServer(s grpc.ServiceRegistrar, srv DirectoryServiceServer) { + // If the following call panics, it indicates UnimplementedDirectoryServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&DirectoryService_ServiceDesc, srv) +} + +func _DirectoryService_SearchDirectory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SearchDirectoryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DirectoryServiceServer).SearchDirectory(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DirectoryService_SearchDirectory_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DirectoryServiceServer).SearchDirectory(ctx, req.(*SearchDirectoryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DirectoryService_ResolveUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ResolveUserRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DirectoryServiceServer).ResolveUser(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DirectoryService_ResolveUser_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DirectoryServiceServer).ResolveUser(ctx, req.(*ResolveUserRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// DirectoryService_ServiceDesc is the grpc.ServiceDesc for DirectoryService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var DirectoryService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "hypershell.provisioner.v1.DirectoryService", + HandlerType: (*DirectoryServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "SearchDirectory", + Handler: _DirectoryService_SearchDirectory_Handler, + }, + { + MethodName: "ResolveUser", + Handler: _DirectoryService_ResolveUser_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "hypershell/provisioner/v1/service_accounts.proto", +} diff --git a/components/api-server/pkg/api/openapi/.openapi-generator/FILES b/components/api-server/pkg/api/openapi/.openapi-generator/FILES index 048e0ecaf..1899a163e 100644 --- a/components/api-server/pkg/api/openapi/.openapi-generator/FILES +++ b/components/api-server/pkg/api/openapi/.openapi-generator/FILES @@ -9,8 +9,17 @@ configuration.go docs/DefaultAPI.md docs/Error.md docs/Gateway.md +docs/GatewayAccessCapabilities.md +docs/GatewayAccessChangeRoleRequest.md +docs/GatewayAccessGrantRequest.md +docs/GatewayAccessGrantResponse.md +docs/GatewayAccessList.md +docs/GatewayAccessListItem.md +docs/GatewayAccessRole.md docs/GatewayAllOfProvisioningConditions.md docs/GatewayCreateRequest.md +docs/GatewayDirectoryUser.md +docs/GatewayDirectoryUserList.md docs/GatewayList.md docs/GatewayNetwork.md docs/GatewayNetworkList.md @@ -48,8 +57,17 @@ go.mod go.sum model_error.go model_gateway.go +model_gateway_access_capabilities.go +model_gateway_access_change_role_request.go +model_gateway_access_grant_request.go +model_gateway_access_grant_response.go +model_gateway_access_list.go +model_gateway_access_list_item.go +model_gateway_access_role.go model_gateway_all_of_provisioning_conditions.go model_gateway_create_request.go +model_gateway_directory_user.go +model_gateway_directory_user_list.go model_gateway_list.go model_gateway_network.go model_gateway_network_list.go diff --git a/components/api-server/pkg/api/openapi/README.md b/components/api-server/pkg/api/openapi/README.md index 253bf467c..ca6d25b8f 100644 --- a/components/api-server/pkg/api/openapi/README.md +++ b/components/api-server/pkg/api/openapi/README.md @@ -78,6 +78,7 @@ All URIs are relative to *http://localhost:8000* Class | Method | HTTP request | Description ------------ | ------------- | ------------- | ------------- +*DefaultAPI* | [**ChangeGatewayAccessRole**](docs/DefaultAPI.md#changegatewayaccessrole) | **Patch** /api/hypershell/v1/gateways/{gateway_id}/access/{user_id} | Change a user's access role on a gateway *DefaultAPI* | [**CreateGateway**](docs/DefaultAPI.md#creategateway) | **Post** /api/hypershell/v1/gateways | Create a new gateway *DefaultAPI* | [**CreateGatewayNetwork**](docs/DefaultAPI.md#creategatewaynetwork) | **Post** /api/hypershell/v1/gateway_networks | Create a new gatewayNetwork *DefaultAPI* | [**CreateGatewayRelease**](docs/DefaultAPI.md#creategatewayrelease) | **Post** /api/hypershell/v1/gateway_releases | Create a new gatewayRelease @@ -99,6 +100,8 @@ Class | Method | HTTP request | Description *DefaultAPI* | [**GetRole**](docs/DefaultAPI.md#getrole) | **Get** /api/hypershell/v1/roles/{id} | Get a role by ID *DefaultAPI* | [**GetRoleBinding**](docs/DefaultAPI.md#getrolebinding) | **Get** /api/hypershell/v1/role_bindings/{id} | Get a role binding by ID *DefaultAPI* | [**GetUser**](docs/DefaultAPI.md#getuser) | **Get** /api/hypershell/v1/users/{id} | Get a registered user by ID +*DefaultAPI* | [**GrantGatewayAccess**](docs/DefaultAPI.md#grantgatewayaccess) | **Post** /api/hypershell/v1/gateways/{gateway_id}/access | Grant a user access to a gateway +*DefaultAPI* | [**ListGatewayAccess**](docs/DefaultAPI.md#listgatewayaccess) | **Get** /api/hypershell/v1/gateways/{gateway_id}/access | List access grants on a gateway *DefaultAPI* | [**ListGatewayNetworks**](docs/DefaultAPI.md#listgatewaynetworks) | **Get** /api/hypershell/v1/gateway_networks | Returns a list of gatewayNetworks *DefaultAPI* | [**ListGatewayReleases**](docs/DefaultAPI.md#listgatewayreleases) | **Get** /api/hypershell/v1/gateway_releases | Returns a list of gatewayReleases *DefaultAPI* | [**ListGatewayServiceAccounts**](docs/DefaultAPI.md#listgatewayserviceaccounts) | **Get** /api/hypershell/v1/gateways/{gateway_id}/service_accounts | List OpenShell gateway service accounts @@ -108,7 +111,9 @@ Class | Method | HTTP request | Description *DefaultAPI* | [**ListRoles**](docs/DefaultAPI.md#listroles) | **Get** /api/hypershell/v1/roles | List all roles *DefaultAPI* | [**ListUsers**](docs/DefaultAPI.md#listusers) | **Get** /api/hypershell/v1/users | List registered users *DefaultAPI* | [**RegisterManagedCluster**](docs/DefaultAPI.md#registermanagedcluster) | **Post** /api/hypershell/v1/managed_clusters/registration | Self-register a control plane as a managed cluster +*DefaultAPI* | [**RevokeGatewayAccess**](docs/DefaultAPI.md#revokegatewayaccess) | **Delete** /api/hypershell/v1/gateways/{gateway_id}/access/{user_id} | Revoke a user's access to a gateway *DefaultAPI* | [**RevokeGatewayServiceAccount**](docs/DefaultAPI.md#revokegatewayserviceaccount) | **Post** /api/hypershell/v1/gateways/{gateway_id}/service_accounts/{service_account_id}/revoke | Permanently revoke an OpenShell gateway service account +*DefaultAPI* | [**SearchGatewayDirectory**](docs/DefaultAPI.md#searchgatewaydirectory) | **Get** /api/hypershell/v1/gateways/{gateway_id}/access/directory | Search the Keycloak realm directory for candidate users *DefaultAPI* | [**UpdateGateway**](docs/DefaultAPI.md#updategateway) | **Patch** /api/hypershell/v1/gateways/{id} | Update an gateway *DefaultAPI* | [**UpdateGatewayNetwork**](docs/DefaultAPI.md#updategatewaynetwork) | **Patch** /api/hypershell/v1/gateway_networks/{id} | Update an gatewayNetwork *DefaultAPI* | [**UpdateGatewayRelease**](docs/DefaultAPI.md#updategatewayrelease) | **Patch** /api/hypershell/v1/gateway_releases/{id} | Update an gatewayRelease @@ -119,8 +124,17 @@ Class | Method | HTTP request | Description - [Error](docs/Error.md) - [Gateway](docs/Gateway.md) + - [GatewayAccessCapabilities](docs/GatewayAccessCapabilities.md) + - [GatewayAccessChangeRoleRequest](docs/GatewayAccessChangeRoleRequest.md) + - [GatewayAccessGrantRequest](docs/GatewayAccessGrantRequest.md) + - [GatewayAccessGrantResponse](docs/GatewayAccessGrantResponse.md) + - [GatewayAccessList](docs/GatewayAccessList.md) + - [GatewayAccessListItem](docs/GatewayAccessListItem.md) + - [GatewayAccessRole](docs/GatewayAccessRole.md) - [GatewayAllOfProvisioningConditions](docs/GatewayAllOfProvisioningConditions.md) - [GatewayCreateRequest](docs/GatewayCreateRequest.md) + - [GatewayDirectoryUser](docs/GatewayDirectoryUser.md) + - [GatewayDirectoryUserList](docs/GatewayDirectoryUserList.md) - [GatewayList](docs/GatewayList.md) - [GatewayNetwork](docs/GatewayNetwork.md) - [GatewayNetworkList](docs/GatewayNetworkList.md) diff --git a/components/api-server/pkg/api/openapi/api/openapi.yaml b/components/api-server/pkg/api/openapi/api/openapi.yaml index 5687121f2..c170b569c 100644 --- a/components/api-server/pkg/api/openapi/api/openapi.yaml +++ b/components/api-server/pkg/api/openapi/api/openapi.yaml @@ -1873,6 +1873,280 @@ paths: $ref: "#/components/schemas/Error" description: Unexpected error occurred summary: Get a registered user by ID + /api/hypershell/v1/gateways/{gateway_id}/access: + get: + operationId: listGatewayAccess + parameters: + - description: Selected Gateway ID + in: path + name: gateway_id + required: true + schema: + type: string + - description: Page number of record list when record list exceeds specified + page size + explode: true + in: query + name: page + required: false + schema: + default: 1 + minimum: 1 + type: integer + style: form + - description: Maximum number of records to return + explode: true + in: query + name: size + required: false + schema: + default: 100 + minimum: 0 + type: integer + style: form + - description: Filter by console role tier + in: query + name: role + required: false + schema: + $ref: "#/components/schemas/GatewayAccessRole" + - description: Specifies the search criteria + explode: true + in: query + name: search + required: false + schema: + type: string + style: form + - in: query + name: sort + required: false + schema: + default: created_at + enum: + - name + - role + - status + - expires_at + - created_at + type: string + - in: query + name: order + required: false + schema: + default: desc + enum: + - asc + - desc + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/GatewayAccessList" + description: Access grants and caller capabilities + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Invalid request or query value + "401": + description: Access token is missing or invalid + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Gateway or service account was not found or is not visible + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Unexpected error occurred + summary: List access grants on a gateway + post: + operationId: grantGatewayAccess + parameters: + - description: Selected Gateway ID + in: path + name: gateway_id + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/GatewayAccessGrantRequest" + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/GatewayAccessGrantResponse" + description: Access granted + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Invalid request or query value + "401": + description: Access token is missing or invalid + "403": + description: Access token does not have sufficient privileges + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Gateway or service account was not found or is not visible + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Gateway is not ready or the active name already exists + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Keycloak provisioning or lifecycle verification is unavailable + summary: Grant a user access to a gateway + /api/hypershell/v1/gateways/{gateway_id}/access/directory: + get: + operationId: searchGatewayDirectory + parameters: + - description: Selected Gateway ID + in: path + name: gateway_id + required: true + schema: + type: string + - description: Specifies the search criteria + explode: true + in: query + name: search + required: false + schema: + type: string + style: form + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/GatewayDirectoryUserList" + description: Candidate realm users + "401": + description: Access token is missing or invalid + "403": + description: Access token does not have sufficient privileges + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Gateway or service account was not found or is not visible + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Keycloak provisioning or lifecycle verification is unavailable + summary: Search the Keycloak realm directory for candidate users + /api/hypershell/v1/gateways/{gateway_id}/access/{user_id}: + delete: + operationId: revokeGatewayAccess + parameters: + - description: Selected Gateway ID + in: path + name: gateway_id + required: true + schema: + type: string + - description: HyperShell User ID + in: path + name: user_id + required: true + schema: + type: string + responses: + "204": + description: Access revoked + "401": + description: Access token is missing or invalid + "403": + description: Access token does not have sufficient privileges + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Gateway or service account was not found or is not visible + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Gateway is not ready or the active name already exists + summary: Revoke a user's access to a gateway + patch: + operationId: changeGatewayAccessRole + parameters: + - description: Selected Gateway ID + in: path + name: gateway_id + required: true + schema: + type: string + - description: HyperShell User ID + in: path + name: user_id + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/GatewayAccessChangeRoleRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/GatewayAccessGrantResponse" + description: Role changed + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Invalid request or query value + "401": + description: Access token is missing or invalid + "403": + description: Access token does not have sufficient privileges + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Gateway or service account was not found or is not visible + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Gateway is not ready or the active name already exists + summary: Change a user's access role on a gateway components: parameters: id: @@ -1977,6 +2251,20 @@ components: required: true schema: type: string + role: + description: Filter by console role tier + in: query + name: role + required: false + schema: + $ref: "#/components/schemas/GatewayAccessRole" + userId: + description: HyperShell User ID + in: path + name: user_id + required: true + schema: + type: string responses: UnauthorizedError: description: Access token is missing or invalid @@ -3264,6 +3552,194 @@ components: href: href email: email username: username + GatewayAccessRole: + enum: + - owner + - admin + - user + type: string + GatewayAccessGrantRequest: + additionalProperties: false + example: + role: owner + subject: subject + username: username + properties: + username: + minLength: 1 + type: string + subject: + description: Optional Keycloak subject reference returned by the directory + search + type: string + role: + $ref: "#/components/schemas/GatewayAccessRole" + required: + - role + - username + type: object + GatewayAccessChangeRoleRequest: + additionalProperties: false + example: + role: owner + properties: + role: + $ref: "#/components/schemas/GatewayAccessRole" + required: + - role + type: object + GatewayAccessListItem: + example: + granted_at: 2000-01-23T04:56:07.000+00:00 + role: null + user_id: user_id + name: name + role_binding_id: role_binding_id + is_creator: true + email: email + username: username + properties: + role_binding_id: + type: string + user_id: + type: string + username: + type: string + name: + nullable: true + type: string + email: + nullable: true + type: string + role: + $ref: "#/components/schemas/GatewayAccessRole" + is_creator: + type: boolean + granted_at: + format: date-time + type: string + required: + - granted_at + - is_creator + - role + - role_binding_id + - user_id + - username + type: object + GatewayAccessGrantResponse: + allOf: + - $ref: "#/components/schemas/GatewayAccessListItem" + example: + granted_at: 2000-01-23T04:56:07.000+00:00 + role: owner + user_id: user_id + name: name + role_binding_id: role_binding_id + is_creator: true + email: email + username: username + GatewayAccessCapabilities: + example: + caller_role: owner + can_manage_access: true + can_manage_owners: true + properties: + caller_role: + $ref: "#/components/schemas/GatewayAccessRole" + can_manage_access: + type: boolean + can_manage_owners: + type: boolean + required: + - can_manage_access + - can_manage_owners + type: object + GatewayAccessList: + example: + total: 1 + capabilities: + caller_role: owner + can_manage_access: true + can_manage_owners: true + size: 6 + page: 0 + items: + - granted_at: 2000-01-23T04:56:07.000+00:00 + role: null + user_id: user_id + name: name + role_binding_id: role_binding_id + is_creator: true + email: email + username: username + - granted_at: 2000-01-23T04:56:07.000+00:00 + role: null + user_id: user_id + name: name + role_binding_id: role_binding_id + is_creator: true + email: email + username: username + properties: + page: + type: integer + size: + type: integer + total: + type: integer + capabilities: + $ref: "#/components/schemas/GatewayAccessCapabilities" + items: + items: + $ref: "#/components/schemas/GatewayAccessListItem" + type: array + required: + - capabilities + - items + - page + - size + - total + type: object + GatewayDirectoryUser: + example: + subject: subject + name: name + email: email + username: username + properties: + username: + type: string + name: + nullable: true + type: string + email: + nullable: true + type: string + subject: + nullable: true + type: string + required: + - username + type: object + GatewayDirectoryUserList: + example: + items: + - subject: subject + name: name + email: email + username: username + - subject: subject + name: name + email: email + username: username + properties: + items: + items: + $ref: "#/components/schemas/GatewayDirectoryUser" + type: array + required: + - items + type: object GatewayCreateRequest: example: phase: phase diff --git a/components/api-server/pkg/api/openapi/api_default.go b/components/api-server/pkg/api/openapi/api_default.go index 4ca4f7150..3f55f3c16 100644 --- a/components/api-server/pkg/api/openapi/api_default.go +++ b/components/api-server/pkg/api/openapi/api_default.go @@ -22,6 +22,155 @@ import ( // DefaultAPIService DefaultAPI service type DefaultAPIService service +type ApiChangeGatewayAccessRoleRequest struct { + ctx context.Context + ApiService *DefaultAPIService + gatewayId string + userId string + gatewayAccessChangeRoleRequest *GatewayAccessChangeRoleRequest +} + +func (r ApiChangeGatewayAccessRoleRequest) GatewayAccessChangeRoleRequest(gatewayAccessChangeRoleRequest GatewayAccessChangeRoleRequest) ApiChangeGatewayAccessRoleRequest { + r.gatewayAccessChangeRoleRequest = &gatewayAccessChangeRoleRequest + return r +} + +func (r ApiChangeGatewayAccessRoleRequest) Execute() (*GatewayAccessGrantResponse, *http.Response, error) { + return r.ApiService.ChangeGatewayAccessRoleExecute(r) +} + +/* +ChangeGatewayAccessRole Change a user's access role on a gateway + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param gatewayId Selected Gateway ID + @param userId HyperShell User ID + @return ApiChangeGatewayAccessRoleRequest +*/ +func (a *DefaultAPIService) ChangeGatewayAccessRole(ctx context.Context, gatewayId string, userId string) ApiChangeGatewayAccessRoleRequest { + return ApiChangeGatewayAccessRoleRequest{ + ApiService: a, + ctx: ctx, + gatewayId: gatewayId, + userId: userId, + } +} + +// Execute executes the request +// +// @return GatewayAccessGrantResponse +func (a *DefaultAPIService) ChangeGatewayAccessRoleExecute(r ApiChangeGatewayAccessRoleRequest) (*GatewayAccessGrantResponse, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPatch + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *GatewayAccessGrantResponse + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DefaultAPIService.ChangeGatewayAccessRole") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/api/hypershell/v1/gateways/{gateway_id}/access/{user_id}" + localVarPath = strings.Replace(localVarPath, "{"+"gateway_id"+"}", url.PathEscape(parameterValueToString(r.gatewayId, "gatewayId")), -1) + localVarPath = strings.Replace(localVarPath, "{"+"user_id"+"}", url.PathEscape(parameterValueToString(r.userId, "userId")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + if r.gatewayAccessChangeRoleRequest == nil { + return localVarReturnValue, nil, reportError("gatewayAccessChangeRoleRequest is required and must be specified") + } + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{"application/json"} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + // body params + localVarPostBody = r.gatewayAccessChangeRoleRequest + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 404 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 409 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + type ApiCreateGatewayRequest struct { ctx context.Context ApiService *DefaultAPIService @@ -2942,108 +3091,65 @@ func (a *DefaultAPIService) GetUserExecute(r ApiGetUserRequest) (*User, *http.Re return localVarReturnValue, localVarHTTPResponse, nil } -type ApiListGatewayNetworksRequest struct { - ctx context.Context - ApiService *DefaultAPIService - page *int32 - size *int32 - search *string - orderBy *string - fields *string -} - -// Page number of record list when record list exceeds specified page size -func (r ApiListGatewayNetworksRequest) Page(page int32) ApiListGatewayNetworksRequest { - r.page = &page - return r -} - -// Maximum number of records to return -func (r ApiListGatewayNetworksRequest) Size(size int32) ApiListGatewayNetworksRequest { - r.size = &size - return r -} - -// Specifies the search criteria -func (r ApiListGatewayNetworksRequest) Search(search string) ApiListGatewayNetworksRequest { - r.search = &search - return r -} - -// Specifies the order by criteria -func (r ApiListGatewayNetworksRequest) OrderBy(orderBy string) ApiListGatewayNetworksRequest { - r.orderBy = &orderBy - return r +type ApiGrantGatewayAccessRequest struct { + ctx context.Context + ApiService *DefaultAPIService + gatewayId string + gatewayAccessGrantRequest *GatewayAccessGrantRequest } -// Supplies a comma-separated list of fields to be returned -func (r ApiListGatewayNetworksRequest) Fields(fields string) ApiListGatewayNetworksRequest { - r.fields = &fields +func (r ApiGrantGatewayAccessRequest) GatewayAccessGrantRequest(gatewayAccessGrantRequest GatewayAccessGrantRequest) ApiGrantGatewayAccessRequest { + r.gatewayAccessGrantRequest = &gatewayAccessGrantRequest return r } -func (r ApiListGatewayNetworksRequest) Execute() (*GatewayNetworkList, *http.Response, error) { - return r.ApiService.ListGatewayNetworksExecute(r) +func (r ApiGrantGatewayAccessRequest) Execute() (*GatewayAccessGrantResponse, *http.Response, error) { + return r.ApiService.GrantGatewayAccessExecute(r) } /* -ListGatewayNetworks Returns a list of gatewayNetworks +GrantGatewayAccess Grant a user access to a gateway @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). - @return ApiListGatewayNetworksRequest + @param gatewayId Selected Gateway ID + @return ApiGrantGatewayAccessRequest */ -func (a *DefaultAPIService) ListGatewayNetworks(ctx context.Context) ApiListGatewayNetworksRequest { - return ApiListGatewayNetworksRequest{ +func (a *DefaultAPIService) GrantGatewayAccess(ctx context.Context, gatewayId string) ApiGrantGatewayAccessRequest { + return ApiGrantGatewayAccessRequest{ ApiService: a, ctx: ctx, + gatewayId: gatewayId, } } // Execute executes the request // -// @return GatewayNetworkList -func (a *DefaultAPIService) ListGatewayNetworksExecute(r ApiListGatewayNetworksRequest) (*GatewayNetworkList, *http.Response, error) { +// @return GatewayAccessGrantResponse +func (a *DefaultAPIService) GrantGatewayAccessExecute(r ApiGrantGatewayAccessRequest) (*GatewayAccessGrantResponse, *http.Response, error) { var ( - localVarHTTPMethod = http.MethodGet + localVarHTTPMethod = http.MethodPost localVarPostBody interface{} formFiles []formFile - localVarReturnValue *GatewayNetworkList + localVarReturnValue *GatewayAccessGrantResponse ) - localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DefaultAPIService.ListGatewayNetworks") + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DefaultAPIService.GrantGatewayAccess") if err != nil { return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} } - localVarPath := localBasePath + "/api/hypershell/v1/gateway_networks" + localVarPath := localBasePath + "/api/hypershell/v1/gateways/{gateway_id}/access" + localVarPath = strings.Replace(localVarPath, "{"+"gateway_id"+"}", url.PathEscape(parameterValueToString(r.gatewayId, "gatewayId")), -1) localVarHeaderParams := make(map[string]string) localVarQueryParams := url.Values{} localVarFormParams := url.Values{} - - if r.page != nil { - parameterAddToHeaderOrQuery(localVarQueryParams, "page", r.page, "form", "") - } else { - var defaultValue int32 = 1 - r.page = &defaultValue - } - if r.size != nil { - parameterAddToHeaderOrQuery(localVarQueryParams, "size", r.size, "form", "") - } else { - var defaultValue int32 = 100 - r.size = &defaultValue - } - if r.search != nil { - parameterAddToHeaderOrQuery(localVarQueryParams, "search", r.search, "form", "") - } - if r.orderBy != nil { - parameterAddToHeaderOrQuery(localVarQueryParams, "orderBy", r.orderBy, "form", "") - } - if r.fields != nil { - parameterAddToHeaderOrQuery(localVarQueryParams, "fields", r.fields, "form", "") + if r.gatewayAccessGrantRequest == nil { + return localVarReturnValue, nil, reportError("gatewayAccessGrantRequest is required and must be specified") } + // to determine the Content-Type header - localVarHTTPContentTypes := []string{} + localVarHTTPContentTypes := []string{"application/json"} // set Content-Type header localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) @@ -3059,6 +3165,8 @@ func (a *DefaultAPIService) ListGatewayNetworksExecute(r ApiListGatewayNetworksR if localVarHTTPHeaderAccept != "" { localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept } + // body params + localVarPostBody = r.gatewayAccessGrantRequest req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) if err != nil { return localVarReturnValue, nil, err @@ -3081,7 +3189,7 @@ func (a *DefaultAPIService) ListGatewayNetworksExecute(r ApiListGatewayNetworksR body: localVarBody, error: localVarHTTPResponse.Status, } - if localVarHTTPResponse.StatusCode == 401 { + if localVarHTTPResponse.StatusCode == 400 { var v Error err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) if err != nil { @@ -3092,7 +3200,7 @@ func (a *DefaultAPIService) ListGatewayNetworksExecute(r ApiListGatewayNetworksR newErr.model = v return localVarReturnValue, localVarHTTPResponse, newErr } - if localVarHTTPResponse.StatusCode == 403 { + if localVarHTTPResponse.StatusCode == 404 { var v Error err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) if err != nil { @@ -3103,7 +3211,18 @@ func (a *DefaultAPIService) ListGatewayNetworksExecute(r ApiListGatewayNetworksR newErr.model = v return localVarReturnValue, localVarHTTPResponse, newErr } - if localVarHTTPResponse.StatusCode == 500 { + if localVarHTTPResponse.StatusCode == 409 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 503 { var v Error err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) if err != nil { @@ -3128,80 +3247,89 @@ func (a *DefaultAPIService) ListGatewayNetworksExecute(r ApiListGatewayNetworksR return localVarReturnValue, localVarHTTPResponse, nil } -type ApiListGatewayReleasesRequest struct { +type ApiListGatewayAccessRequest struct { ctx context.Context ApiService *DefaultAPIService + gatewayId string page *int32 size *int32 + role *GatewayAccessRole search *string - orderBy *string - fields *string + sort *string + order *string } // Page number of record list when record list exceeds specified page size -func (r ApiListGatewayReleasesRequest) Page(page int32) ApiListGatewayReleasesRequest { +func (r ApiListGatewayAccessRequest) Page(page int32) ApiListGatewayAccessRequest { r.page = &page return r } // Maximum number of records to return -func (r ApiListGatewayReleasesRequest) Size(size int32) ApiListGatewayReleasesRequest { +func (r ApiListGatewayAccessRequest) Size(size int32) ApiListGatewayAccessRequest { r.size = &size return r } +// Filter by console role tier +func (r ApiListGatewayAccessRequest) Role(role GatewayAccessRole) ApiListGatewayAccessRequest { + r.role = &role + return r +} + // Specifies the search criteria -func (r ApiListGatewayReleasesRequest) Search(search string) ApiListGatewayReleasesRequest { +func (r ApiListGatewayAccessRequest) Search(search string) ApiListGatewayAccessRequest { r.search = &search return r } -// Specifies the order by criteria -func (r ApiListGatewayReleasesRequest) OrderBy(orderBy string) ApiListGatewayReleasesRequest { - r.orderBy = &orderBy +func (r ApiListGatewayAccessRequest) Sort(sort string) ApiListGatewayAccessRequest { + r.sort = &sort return r } -// Supplies a comma-separated list of fields to be returned -func (r ApiListGatewayReleasesRequest) Fields(fields string) ApiListGatewayReleasesRequest { - r.fields = &fields +func (r ApiListGatewayAccessRequest) Order(order string) ApiListGatewayAccessRequest { + r.order = &order return r } -func (r ApiListGatewayReleasesRequest) Execute() (*GatewayReleaseList, *http.Response, error) { - return r.ApiService.ListGatewayReleasesExecute(r) +func (r ApiListGatewayAccessRequest) Execute() (*GatewayAccessList, *http.Response, error) { + return r.ApiService.ListGatewayAccessExecute(r) } /* -ListGatewayReleases Returns a list of gatewayReleases +ListGatewayAccess List access grants on a gateway @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). - @return ApiListGatewayReleasesRequest + @param gatewayId Selected Gateway ID + @return ApiListGatewayAccessRequest */ -func (a *DefaultAPIService) ListGatewayReleases(ctx context.Context) ApiListGatewayReleasesRequest { - return ApiListGatewayReleasesRequest{ +func (a *DefaultAPIService) ListGatewayAccess(ctx context.Context, gatewayId string) ApiListGatewayAccessRequest { + return ApiListGatewayAccessRequest{ ApiService: a, ctx: ctx, + gatewayId: gatewayId, } } // Execute executes the request // -// @return GatewayReleaseList -func (a *DefaultAPIService) ListGatewayReleasesExecute(r ApiListGatewayReleasesRequest) (*GatewayReleaseList, *http.Response, error) { +// @return GatewayAccessList +func (a *DefaultAPIService) ListGatewayAccessExecute(r ApiListGatewayAccessRequest) (*GatewayAccessList, *http.Response, error) { var ( localVarHTTPMethod = http.MethodGet localVarPostBody interface{} formFiles []formFile - localVarReturnValue *GatewayReleaseList + localVarReturnValue *GatewayAccessList ) - localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DefaultAPIService.ListGatewayReleases") + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DefaultAPIService.ListGatewayAccess") if err != nil { return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} } - localVarPath := localBasePath + "/api/hypershell/v1/gateway_releases" + localVarPath := localBasePath + "/api/hypershell/v1/gateways/{gateway_id}/access" + localVarPath = strings.Replace(localVarPath, "{"+"gateway_id"+"}", url.PathEscape(parameterValueToString(r.gatewayId, "gatewayId")), -1) localVarHeaderParams := make(map[string]string) localVarQueryParams := url.Values{} @@ -3219,13 +3347,394 @@ func (a *DefaultAPIService) ListGatewayReleasesExecute(r ApiListGatewayReleasesR var defaultValue int32 = 100 r.size = &defaultValue } + if r.role != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "role", r.role, "", "") + } if r.search != nil { parameterAddToHeaderOrQuery(localVarQueryParams, "search", r.search, "form", "") } - if r.orderBy != nil { - parameterAddToHeaderOrQuery(localVarQueryParams, "orderBy", r.orderBy, "form", "") - } - if r.fields != nil { + if r.sort != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "sort", r.sort, "", "") + } else { + var defaultValue string = "created_at" + r.sort = &defaultValue + } + if r.order != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "order", r.order, "", "") + } else { + var defaultValue string = "desc" + r.order = &defaultValue + } + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 404 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 500 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + +type ApiListGatewayNetworksRequest struct { + ctx context.Context + ApiService *DefaultAPIService + page *int32 + size *int32 + search *string + orderBy *string + fields *string +} + +// Page number of record list when record list exceeds specified page size +func (r ApiListGatewayNetworksRequest) Page(page int32) ApiListGatewayNetworksRequest { + r.page = &page + return r +} + +// Maximum number of records to return +func (r ApiListGatewayNetworksRequest) Size(size int32) ApiListGatewayNetworksRequest { + r.size = &size + return r +} + +// Specifies the search criteria +func (r ApiListGatewayNetworksRequest) Search(search string) ApiListGatewayNetworksRequest { + r.search = &search + return r +} + +// Specifies the order by criteria +func (r ApiListGatewayNetworksRequest) OrderBy(orderBy string) ApiListGatewayNetworksRequest { + r.orderBy = &orderBy + return r +} + +// Supplies a comma-separated list of fields to be returned +func (r ApiListGatewayNetworksRequest) Fields(fields string) ApiListGatewayNetworksRequest { + r.fields = &fields + return r +} + +func (r ApiListGatewayNetworksRequest) Execute() (*GatewayNetworkList, *http.Response, error) { + return r.ApiService.ListGatewayNetworksExecute(r) +} + +/* +ListGatewayNetworks Returns a list of gatewayNetworks + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return ApiListGatewayNetworksRequest +*/ +func (a *DefaultAPIService) ListGatewayNetworks(ctx context.Context) ApiListGatewayNetworksRequest { + return ApiListGatewayNetworksRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +// +// @return GatewayNetworkList +func (a *DefaultAPIService) ListGatewayNetworksExecute(r ApiListGatewayNetworksRequest) (*GatewayNetworkList, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *GatewayNetworkList + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DefaultAPIService.ListGatewayNetworks") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/api/hypershell/v1/gateway_networks" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + if r.page != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "page", r.page, "form", "") + } else { + var defaultValue int32 = 1 + r.page = &defaultValue + } + if r.size != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "size", r.size, "form", "") + } else { + var defaultValue int32 = 100 + r.size = &defaultValue + } + if r.search != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "search", r.search, "form", "") + } + if r.orderBy != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "orderBy", r.orderBy, "form", "") + } + if r.fields != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "fields", r.fields, "form", "") + } + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 401 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 403 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 500 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + +type ApiListGatewayReleasesRequest struct { + ctx context.Context + ApiService *DefaultAPIService + page *int32 + size *int32 + search *string + orderBy *string + fields *string +} + +// Page number of record list when record list exceeds specified page size +func (r ApiListGatewayReleasesRequest) Page(page int32) ApiListGatewayReleasesRequest { + r.page = &page + return r +} + +// Maximum number of records to return +func (r ApiListGatewayReleasesRequest) Size(size int32) ApiListGatewayReleasesRequest { + r.size = &size + return r +} + +// Specifies the search criteria +func (r ApiListGatewayReleasesRequest) Search(search string) ApiListGatewayReleasesRequest { + r.search = &search + return r +} + +// Specifies the order by criteria +func (r ApiListGatewayReleasesRequest) OrderBy(orderBy string) ApiListGatewayReleasesRequest { + r.orderBy = &orderBy + return r +} + +// Supplies a comma-separated list of fields to be returned +func (r ApiListGatewayReleasesRequest) Fields(fields string) ApiListGatewayReleasesRequest { + r.fields = &fields + return r +} + +func (r ApiListGatewayReleasesRequest) Execute() (*GatewayReleaseList, *http.Response, error) { + return r.ApiService.ListGatewayReleasesExecute(r) +} + +/* +ListGatewayReleases Returns a list of gatewayReleases + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return ApiListGatewayReleasesRequest +*/ +func (a *DefaultAPIService) ListGatewayReleases(ctx context.Context) ApiListGatewayReleasesRequest { + return ApiListGatewayReleasesRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +// +// @return GatewayReleaseList +func (a *DefaultAPIService) ListGatewayReleasesExecute(r ApiListGatewayReleasesRequest) (*GatewayReleaseList, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *GatewayReleaseList + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DefaultAPIService.ListGatewayReleases") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/api/hypershell/v1/gateway_releases" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + if r.page != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "page", r.page, "form", "") + } else { + var defaultValue int32 = 1 + r.page = &defaultValue + } + if r.size != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "size", r.size, "form", "") + } else { + var defaultValue int32 = 100 + r.size = &defaultValue + } + if r.search != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "search", r.search, "form", "") + } + if r.orderBy != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "orderBy", r.orderBy, "form", "") + } + if r.fields != nil { parameterAddToHeaderOrQuery(localVarQueryParams, "fields", r.fields, "form", "") } // to determine the Content-Type header @@ -4552,6 +5061,121 @@ func (a *DefaultAPIService) RegisterManagedClusterExecute(r ApiRegisterManagedCl return localVarReturnValue, localVarHTTPResponse, nil } +type ApiRevokeGatewayAccessRequest struct { + ctx context.Context + ApiService *DefaultAPIService + gatewayId string + userId string +} + +func (r ApiRevokeGatewayAccessRequest) Execute() (*http.Response, error) { + return r.ApiService.RevokeGatewayAccessExecute(r) +} + +/* +RevokeGatewayAccess Revoke a user's access to a gateway + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param gatewayId Selected Gateway ID + @param userId HyperShell User ID + @return ApiRevokeGatewayAccessRequest +*/ +func (a *DefaultAPIService) RevokeGatewayAccess(ctx context.Context, gatewayId string, userId string) ApiRevokeGatewayAccessRequest { + return ApiRevokeGatewayAccessRequest{ + ApiService: a, + ctx: ctx, + gatewayId: gatewayId, + userId: userId, + } +} + +// Execute executes the request +func (a *DefaultAPIService) RevokeGatewayAccessExecute(r ApiRevokeGatewayAccessRequest) (*http.Response, error) { + var ( + localVarHTTPMethod = http.MethodDelete + localVarPostBody interface{} + formFiles []formFile + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DefaultAPIService.RevokeGatewayAccess") + if err != nil { + return nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/api/hypershell/v1/gateways/{gateway_id}/access/{user_id}" + localVarPath = strings.Replace(localVarPath, "{"+"gateway_id"+"}", url.PathEscape(parameterValueToString(r.gatewayId, "gatewayId")), -1) + localVarPath = strings.Replace(localVarPath, "{"+"user_id"+"}", url.PathEscape(parameterValueToString(r.userId, "userId")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 404 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 409 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarHTTPResponse, newErr + } + + return localVarHTTPResponse, nil +} + type ApiRevokeGatewayServiceAccountRequest struct { ctx context.Context ApiService *DefaultAPIService @@ -4679,6 +5303,139 @@ func (a *DefaultAPIService) RevokeGatewayServiceAccountExecute(r ApiRevokeGatewa return localVarReturnValue, localVarHTTPResponse, nil } +type ApiSearchGatewayDirectoryRequest struct { + ctx context.Context + ApiService *DefaultAPIService + gatewayId string + search *string +} + +// Specifies the search criteria +func (r ApiSearchGatewayDirectoryRequest) Search(search string) ApiSearchGatewayDirectoryRequest { + r.search = &search + return r +} + +func (r ApiSearchGatewayDirectoryRequest) Execute() (*GatewayDirectoryUserList, *http.Response, error) { + return r.ApiService.SearchGatewayDirectoryExecute(r) +} + +/* +SearchGatewayDirectory Search the Keycloak realm directory for candidate users + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param gatewayId Selected Gateway ID + @return ApiSearchGatewayDirectoryRequest +*/ +func (a *DefaultAPIService) SearchGatewayDirectory(ctx context.Context, gatewayId string) ApiSearchGatewayDirectoryRequest { + return ApiSearchGatewayDirectoryRequest{ + ApiService: a, + ctx: ctx, + gatewayId: gatewayId, + } +} + +// Execute executes the request +// +// @return GatewayDirectoryUserList +func (a *DefaultAPIService) SearchGatewayDirectoryExecute(r ApiSearchGatewayDirectoryRequest) (*GatewayDirectoryUserList, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *GatewayDirectoryUserList + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DefaultAPIService.SearchGatewayDirectory") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/api/hypershell/v1/gateways/{gateway_id}/access/directory" + localVarPath = strings.Replace(localVarPath, "{"+"gateway_id"+"}", url.PathEscape(parameterValueToString(r.gatewayId, "gatewayId")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + if r.search != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "search", r.search, "form", "") + } + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 404 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 503 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + type ApiUpdateGatewayRequest struct { ctx context.Context ApiService *DefaultAPIService diff --git a/components/api-server/pkg/api/openapi/docs/DefaultAPI.md b/components/api-server/pkg/api/openapi/docs/DefaultAPI.md index 547619991..6630aea36 100644 --- a/components/api-server/pkg/api/openapi/docs/DefaultAPI.md +++ b/components/api-server/pkg/api/openapi/docs/DefaultAPI.md @@ -4,6 +4,7 @@ All URIs are relative to *http://localhost:8000* Method | HTTP request | Description ------------- | ------------- | ------------- +[**ChangeGatewayAccessRole**](DefaultAPI.md#ChangeGatewayAccessRole) | **Patch** /api/hypershell/v1/gateways/{gateway_id}/access/{user_id} | Change a user's access role on a gateway [**CreateGateway**](DefaultAPI.md#CreateGateway) | **Post** /api/hypershell/v1/gateways | Create a new gateway [**CreateGatewayNetwork**](DefaultAPI.md#CreateGatewayNetwork) | **Post** /api/hypershell/v1/gateway_networks | Create a new gatewayNetwork [**CreateGatewayRelease**](DefaultAPI.md#CreateGatewayRelease) | **Post** /api/hypershell/v1/gateway_releases | Create a new gatewayRelease @@ -25,6 +26,8 @@ Method | HTTP request | Description [**GetRole**](DefaultAPI.md#GetRole) | **Get** /api/hypershell/v1/roles/{id} | Get a role by ID [**GetRoleBinding**](DefaultAPI.md#GetRoleBinding) | **Get** /api/hypershell/v1/role_bindings/{id} | Get a role binding by ID [**GetUser**](DefaultAPI.md#GetUser) | **Get** /api/hypershell/v1/users/{id} | Get a registered user by ID +[**GrantGatewayAccess**](DefaultAPI.md#GrantGatewayAccess) | **Post** /api/hypershell/v1/gateways/{gateway_id}/access | Grant a user access to a gateway +[**ListGatewayAccess**](DefaultAPI.md#ListGatewayAccess) | **Get** /api/hypershell/v1/gateways/{gateway_id}/access | List access grants on a gateway [**ListGatewayNetworks**](DefaultAPI.md#ListGatewayNetworks) | **Get** /api/hypershell/v1/gateway_networks | Returns a list of gatewayNetworks [**ListGatewayReleases**](DefaultAPI.md#ListGatewayReleases) | **Get** /api/hypershell/v1/gateway_releases | Returns a list of gatewayReleases [**ListGatewayServiceAccounts**](DefaultAPI.md#ListGatewayServiceAccounts) | **Get** /api/hypershell/v1/gateways/{gateway_id}/service_accounts | List OpenShell gateway service accounts @@ -34,7 +37,9 @@ Method | HTTP request | Description [**ListRoles**](DefaultAPI.md#ListRoles) | **Get** /api/hypershell/v1/roles | List all roles [**ListUsers**](DefaultAPI.md#ListUsers) | **Get** /api/hypershell/v1/users | List registered users [**RegisterManagedCluster**](DefaultAPI.md#RegisterManagedCluster) | **Post** /api/hypershell/v1/managed_clusters/registration | Self-register a control plane as a managed cluster +[**RevokeGatewayAccess**](DefaultAPI.md#RevokeGatewayAccess) | **Delete** /api/hypershell/v1/gateways/{gateway_id}/access/{user_id} | Revoke a user's access to a gateway [**RevokeGatewayServiceAccount**](DefaultAPI.md#RevokeGatewayServiceAccount) | **Post** /api/hypershell/v1/gateways/{gateway_id}/service_accounts/{service_account_id}/revoke | Permanently revoke an OpenShell gateway service account +[**SearchGatewayDirectory**](DefaultAPI.md#SearchGatewayDirectory) | **Get** /api/hypershell/v1/gateways/{gateway_id}/access/directory | Search the Keycloak realm directory for candidate users [**UpdateGateway**](DefaultAPI.md#UpdateGateway) | **Patch** /api/hypershell/v1/gateways/{id} | Update an gateway [**UpdateGatewayNetwork**](DefaultAPI.md#UpdateGatewayNetwork) | **Patch** /api/hypershell/v1/gateway_networks/{id} | Update an gatewayNetwork [**UpdateGatewayRelease**](DefaultAPI.md#UpdateGatewayRelease) | **Patch** /api/hypershell/v1/gateway_releases/{id} | Update an gatewayRelease @@ -42,6 +47,79 @@ Method | HTTP request | Description +## ChangeGatewayAccessRole + +> GatewayAccessGrantResponse ChangeGatewayAccessRole(ctx, gatewayId, userId).GatewayAccessChangeRoleRequest(gatewayAccessChangeRoleRequest).Execute() + +Change a user's access role on a gateway + +### Example + +```go +package main + +import ( + "context" + "fmt" + "os" + openapiclient "github.com/GIT_USER_ID/GIT_REPO_ID" +) + +func main() { + gatewayId := "gatewayId_example" // string | Selected Gateway ID + userId := "userId_example" // string | HyperShell User ID + gatewayAccessChangeRoleRequest := *openapiclient.NewGatewayAccessChangeRoleRequest(openapiclient.GatewayAccessRole("owner")) // GatewayAccessChangeRoleRequest | + + configuration := openapiclient.NewConfiguration() + apiClient := openapiclient.NewAPIClient(configuration) + resp, r, err := apiClient.DefaultAPI.ChangeGatewayAccessRole(context.Background(), gatewayId, userId).GatewayAccessChangeRoleRequest(gatewayAccessChangeRoleRequest).Execute() + if err != nil { + fmt.Fprintf(os.Stderr, "Error when calling `DefaultAPI.ChangeGatewayAccessRole``: %v\n", err) + fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r) + } + // response from `ChangeGatewayAccessRole`: GatewayAccessGrantResponse + fmt.Fprintf(os.Stdout, "Response from `DefaultAPI.ChangeGatewayAccessRole`: %v\n", resp) +} +``` + +### Path Parameters + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- +**ctx** | **context.Context** | context for authentication, logging, cancellation, deadlines, tracing, etc. +**gatewayId** | **string** | Selected Gateway ID | +**userId** | **string** | HyperShell User ID | + +### Other Parameters + +Other parameters are passed through a pointer to a apiChangeGatewayAccessRoleRequest struct via the builder pattern + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + + + **gatewayAccessChangeRoleRequest** | [**GatewayAccessChangeRoleRequest**](GatewayAccessChangeRoleRequest.md) | | + +### Return type + +[**GatewayAccessGrantResponse**](GatewayAccessGrantResponse.md) + +### Authorization + +[Bearer](../README.md#Bearer) + +### HTTP request headers + +- **Content-Type**: application/json +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) +[[Back to Model list]](../README.md#documentation-for-models) +[[Back to README]](../README.md) + + ## CreateGateway > Gateway CreateGateway(ctx).GatewayCreateRequest(gatewayCreateRequest).Execute() @@ -1439,6 +1517,156 @@ Name | Type | Description | Notes [[Back to README]](../README.md) +## GrantGatewayAccess + +> GatewayAccessGrantResponse GrantGatewayAccess(ctx, gatewayId).GatewayAccessGrantRequest(gatewayAccessGrantRequest).Execute() + +Grant a user access to a gateway + +### Example + +```go +package main + +import ( + "context" + "fmt" + "os" + openapiclient "github.com/GIT_USER_ID/GIT_REPO_ID" +) + +func main() { + gatewayId := "gatewayId_example" // string | Selected Gateway ID + gatewayAccessGrantRequest := *openapiclient.NewGatewayAccessGrantRequest("Username_example", openapiclient.GatewayAccessRole("owner")) // GatewayAccessGrantRequest | + + configuration := openapiclient.NewConfiguration() + apiClient := openapiclient.NewAPIClient(configuration) + resp, r, err := apiClient.DefaultAPI.GrantGatewayAccess(context.Background(), gatewayId).GatewayAccessGrantRequest(gatewayAccessGrantRequest).Execute() + if err != nil { + fmt.Fprintf(os.Stderr, "Error when calling `DefaultAPI.GrantGatewayAccess``: %v\n", err) + fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r) + } + // response from `GrantGatewayAccess`: GatewayAccessGrantResponse + fmt.Fprintf(os.Stdout, "Response from `DefaultAPI.GrantGatewayAccess`: %v\n", resp) +} +``` + +### Path Parameters + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- +**ctx** | **context.Context** | context for authentication, logging, cancellation, deadlines, tracing, etc. +**gatewayId** | **string** | Selected Gateway ID | + +### Other Parameters + +Other parameters are passed through a pointer to a apiGrantGatewayAccessRequest struct via the builder pattern + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + + **gatewayAccessGrantRequest** | [**GatewayAccessGrantRequest**](GatewayAccessGrantRequest.md) | | + +### Return type + +[**GatewayAccessGrantResponse**](GatewayAccessGrantResponse.md) + +### Authorization + +[Bearer](../README.md#Bearer) + +### HTTP request headers + +- **Content-Type**: application/json +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) +[[Back to Model list]](../README.md#documentation-for-models) +[[Back to README]](../README.md) + + +## ListGatewayAccess + +> GatewayAccessList ListGatewayAccess(ctx, gatewayId).Page(page).Size(size).Role(role).Search(search).Sort(sort).Order(order).Execute() + +List access grants on a gateway + +### Example + +```go +package main + +import ( + "context" + "fmt" + "os" + openapiclient "github.com/GIT_USER_ID/GIT_REPO_ID" +) + +func main() { + gatewayId := "gatewayId_example" // string | Selected Gateway ID + page := int32(56) // int32 | Page number of record list when record list exceeds specified page size (optional) (default to 1) + size := int32(56) // int32 | Maximum number of records to return (optional) (default to 100) + role := openapiclient.GatewayAccessRole("owner") // GatewayAccessRole | Filter by console role tier (optional) + search := "search_example" // string | Specifies the search criteria (optional) + sort := "sort_example" // string | (optional) (default to "created_at") + order := "order_example" // string | (optional) (default to "desc") + + configuration := openapiclient.NewConfiguration() + apiClient := openapiclient.NewAPIClient(configuration) + resp, r, err := apiClient.DefaultAPI.ListGatewayAccess(context.Background(), gatewayId).Page(page).Size(size).Role(role).Search(search).Sort(sort).Order(order).Execute() + if err != nil { + fmt.Fprintf(os.Stderr, "Error when calling `DefaultAPI.ListGatewayAccess``: %v\n", err) + fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r) + } + // response from `ListGatewayAccess`: GatewayAccessList + fmt.Fprintf(os.Stdout, "Response from `DefaultAPI.ListGatewayAccess`: %v\n", resp) +} +``` + +### Path Parameters + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- +**ctx** | **context.Context** | context for authentication, logging, cancellation, deadlines, tracing, etc. +**gatewayId** | **string** | Selected Gateway ID | + +### Other Parameters + +Other parameters are passed through a pointer to a apiListGatewayAccessRequest struct via the builder pattern + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + + **page** | **int32** | Page number of record list when record list exceeds specified page size | [default to 1] + **size** | **int32** | Maximum number of records to return | [default to 100] + **role** | [**GatewayAccessRole**](GatewayAccessRole.md) | Filter by console role tier | + **search** | **string** | Specifies the search criteria | + **sort** | **string** | | [default to "created_at"] + **order** | **string** | | [default to "desc"] + +### Return type + +[**GatewayAccessList**](GatewayAccessList.md) + +### Authorization + +[Bearer](../README.md#Bearer) + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) +[[Back to Model list]](../README.md#documentation-for-models) +[[Back to README]](../README.md) + + ## ListGatewayNetworks > GatewayNetworkList ListGatewayNetworks(ctx).Page(page).Size(size).Search(search).OrderBy(orderBy).Fields(fields).Execute() @@ -2089,6 +2317,75 @@ Name | Type | Description | Notes [[Back to README]](../README.md) +## RevokeGatewayAccess + +> RevokeGatewayAccess(ctx, gatewayId, userId).Execute() + +Revoke a user's access to a gateway + +### Example + +```go +package main + +import ( + "context" + "fmt" + "os" + openapiclient "github.com/GIT_USER_ID/GIT_REPO_ID" +) + +func main() { + gatewayId := "gatewayId_example" // string | Selected Gateway ID + userId := "userId_example" // string | HyperShell User ID + + configuration := openapiclient.NewConfiguration() + apiClient := openapiclient.NewAPIClient(configuration) + r, err := apiClient.DefaultAPI.RevokeGatewayAccess(context.Background(), gatewayId, userId).Execute() + if err != nil { + fmt.Fprintf(os.Stderr, "Error when calling `DefaultAPI.RevokeGatewayAccess``: %v\n", err) + fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r) + } +} +``` + +### Path Parameters + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- +**ctx** | **context.Context** | context for authentication, logging, cancellation, deadlines, tracing, etc. +**gatewayId** | **string** | Selected Gateway ID | +**userId** | **string** | HyperShell User ID | + +### Other Parameters + +Other parameters are passed through a pointer to a apiRevokeGatewayAccessRequest struct via the builder pattern + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + + + +### Return type + + (empty response body) + +### Authorization + +[Bearer](../README.md#Bearer) + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) +[[Back to Model list]](../README.md#documentation-for-models) +[[Back to README]](../README.md) + + ## RevokeGatewayServiceAccount > OpenShellGatewayServiceAccountListItem RevokeGatewayServiceAccount(ctx, gatewayId, serviceAccountId).Execute() @@ -2160,6 +2457,76 @@ Name | Type | Description | Notes [[Back to README]](../README.md) +## SearchGatewayDirectory + +> GatewayDirectoryUserList SearchGatewayDirectory(ctx, gatewayId).Search(search).Execute() + +Search the Keycloak realm directory for candidate users + +### Example + +```go +package main + +import ( + "context" + "fmt" + "os" + openapiclient "github.com/GIT_USER_ID/GIT_REPO_ID" +) + +func main() { + gatewayId := "gatewayId_example" // string | Selected Gateway ID + search := "search_example" // string | Specifies the search criteria (optional) + + configuration := openapiclient.NewConfiguration() + apiClient := openapiclient.NewAPIClient(configuration) + resp, r, err := apiClient.DefaultAPI.SearchGatewayDirectory(context.Background(), gatewayId).Search(search).Execute() + if err != nil { + fmt.Fprintf(os.Stderr, "Error when calling `DefaultAPI.SearchGatewayDirectory``: %v\n", err) + fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r) + } + // response from `SearchGatewayDirectory`: GatewayDirectoryUserList + fmt.Fprintf(os.Stdout, "Response from `DefaultAPI.SearchGatewayDirectory`: %v\n", resp) +} +``` + +### Path Parameters + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- +**ctx** | **context.Context** | context for authentication, logging, cancellation, deadlines, tracing, etc. +**gatewayId** | **string** | Selected Gateway ID | + +### Other Parameters + +Other parameters are passed through a pointer to a apiSearchGatewayDirectoryRequest struct via the builder pattern + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + + **search** | **string** | Specifies the search criteria | + +### Return type + +[**GatewayDirectoryUserList**](GatewayDirectoryUserList.md) + +### Authorization + +[Bearer](../README.md#Bearer) + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) +[[Back to Model list]](../README.md#documentation-for-models) +[[Back to README]](../README.md) + + ## UpdateGateway > Gateway UpdateGateway(ctx, id).GatewayPatchRequest(gatewayPatchRequest).Execute() diff --git a/components/api-server/pkg/api/openapi/docs/GatewayAccessCapabilities.md b/components/api-server/pkg/api/openapi/docs/GatewayAccessCapabilities.md new file mode 100644 index 000000000..848724597 --- /dev/null +++ b/components/api-server/pkg/api/openapi/docs/GatewayAccessCapabilities.md @@ -0,0 +1,98 @@ +# GatewayAccessCapabilities + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**CallerRole** | Pointer to [**GatewayAccessRole**](GatewayAccessRole.md) | | [optional] +**CanManageAccess** | **bool** | | +**CanManageOwners** | **bool** | | + +## Methods + +### NewGatewayAccessCapabilities + +`func NewGatewayAccessCapabilities(canManageAccess bool, canManageOwners bool, ) *GatewayAccessCapabilities` + +NewGatewayAccessCapabilities instantiates a new GatewayAccessCapabilities object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewGatewayAccessCapabilitiesWithDefaults + +`func NewGatewayAccessCapabilitiesWithDefaults() *GatewayAccessCapabilities` + +NewGatewayAccessCapabilitiesWithDefaults instantiates a new GatewayAccessCapabilities object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetCallerRole + +`func (o *GatewayAccessCapabilities) GetCallerRole() GatewayAccessRole` + +GetCallerRole returns the CallerRole field if non-nil, zero value otherwise. + +### GetCallerRoleOk + +`func (o *GatewayAccessCapabilities) GetCallerRoleOk() (*GatewayAccessRole, bool)` + +GetCallerRoleOk returns a tuple with the CallerRole field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetCallerRole + +`func (o *GatewayAccessCapabilities) SetCallerRole(v GatewayAccessRole)` + +SetCallerRole sets CallerRole field to given value. + +### HasCallerRole + +`func (o *GatewayAccessCapabilities) HasCallerRole() bool` + +HasCallerRole returns a boolean if a field has been set. + +### GetCanManageAccess + +`func (o *GatewayAccessCapabilities) GetCanManageAccess() bool` + +GetCanManageAccess returns the CanManageAccess field if non-nil, zero value otherwise. + +### GetCanManageAccessOk + +`func (o *GatewayAccessCapabilities) GetCanManageAccessOk() (*bool, bool)` + +GetCanManageAccessOk returns a tuple with the CanManageAccess field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetCanManageAccess + +`func (o *GatewayAccessCapabilities) SetCanManageAccess(v bool)` + +SetCanManageAccess sets CanManageAccess field to given value. + + +### GetCanManageOwners + +`func (o *GatewayAccessCapabilities) GetCanManageOwners() bool` + +GetCanManageOwners returns the CanManageOwners field if non-nil, zero value otherwise. + +### GetCanManageOwnersOk + +`func (o *GatewayAccessCapabilities) GetCanManageOwnersOk() (*bool, bool)` + +GetCanManageOwnersOk returns a tuple with the CanManageOwners field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetCanManageOwners + +`func (o *GatewayAccessCapabilities) SetCanManageOwners(v bool)` + +SetCanManageOwners sets CanManageOwners field to given value. + + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/components/api-server/pkg/api/openapi/docs/GatewayAccessChangeRoleRequest.md b/components/api-server/pkg/api/openapi/docs/GatewayAccessChangeRoleRequest.md new file mode 100644 index 000000000..473f76c1f --- /dev/null +++ b/components/api-server/pkg/api/openapi/docs/GatewayAccessChangeRoleRequest.md @@ -0,0 +1,51 @@ +# GatewayAccessChangeRoleRequest + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Role** | [**GatewayAccessRole**](GatewayAccessRole.md) | | + +## Methods + +### NewGatewayAccessChangeRoleRequest + +`func NewGatewayAccessChangeRoleRequest(role GatewayAccessRole, ) *GatewayAccessChangeRoleRequest` + +NewGatewayAccessChangeRoleRequest instantiates a new GatewayAccessChangeRoleRequest object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewGatewayAccessChangeRoleRequestWithDefaults + +`func NewGatewayAccessChangeRoleRequestWithDefaults() *GatewayAccessChangeRoleRequest` + +NewGatewayAccessChangeRoleRequestWithDefaults instantiates a new GatewayAccessChangeRoleRequest object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetRole + +`func (o *GatewayAccessChangeRoleRequest) GetRole() GatewayAccessRole` + +GetRole returns the Role field if non-nil, zero value otherwise. + +### GetRoleOk + +`func (o *GatewayAccessChangeRoleRequest) GetRoleOk() (*GatewayAccessRole, bool)` + +GetRoleOk returns a tuple with the Role field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetRole + +`func (o *GatewayAccessChangeRoleRequest) SetRole(v GatewayAccessRole)` + +SetRole sets Role field to given value. + + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/components/api-server/pkg/api/openapi/docs/GatewayAccessGrantRequest.md b/components/api-server/pkg/api/openapi/docs/GatewayAccessGrantRequest.md new file mode 100644 index 000000000..9ef9cbe49 --- /dev/null +++ b/components/api-server/pkg/api/openapi/docs/GatewayAccessGrantRequest.md @@ -0,0 +1,98 @@ +# GatewayAccessGrantRequest + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Username** | **string** | | +**Subject** | Pointer to **string** | Optional Keycloak subject reference returned by the directory search | [optional] +**Role** | [**GatewayAccessRole**](GatewayAccessRole.md) | | + +## Methods + +### NewGatewayAccessGrantRequest + +`func NewGatewayAccessGrantRequest(username string, role GatewayAccessRole, ) *GatewayAccessGrantRequest` + +NewGatewayAccessGrantRequest instantiates a new GatewayAccessGrantRequest object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewGatewayAccessGrantRequestWithDefaults + +`func NewGatewayAccessGrantRequestWithDefaults() *GatewayAccessGrantRequest` + +NewGatewayAccessGrantRequestWithDefaults instantiates a new GatewayAccessGrantRequest object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetUsername + +`func (o *GatewayAccessGrantRequest) GetUsername() string` + +GetUsername returns the Username field if non-nil, zero value otherwise. + +### GetUsernameOk + +`func (o *GatewayAccessGrantRequest) GetUsernameOk() (*string, bool)` + +GetUsernameOk returns a tuple with the Username field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetUsername + +`func (o *GatewayAccessGrantRequest) SetUsername(v string)` + +SetUsername sets Username field to given value. + + +### GetSubject + +`func (o *GatewayAccessGrantRequest) GetSubject() string` + +GetSubject returns the Subject field if non-nil, zero value otherwise. + +### GetSubjectOk + +`func (o *GatewayAccessGrantRequest) GetSubjectOk() (*string, bool)` + +GetSubjectOk returns a tuple with the Subject field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetSubject + +`func (o *GatewayAccessGrantRequest) SetSubject(v string)` + +SetSubject sets Subject field to given value. + +### HasSubject + +`func (o *GatewayAccessGrantRequest) HasSubject() bool` + +HasSubject returns a boolean if a field has been set. + +### GetRole + +`func (o *GatewayAccessGrantRequest) GetRole() GatewayAccessRole` + +GetRole returns the Role field if non-nil, zero value otherwise. + +### GetRoleOk + +`func (o *GatewayAccessGrantRequest) GetRoleOk() (*GatewayAccessRole, bool)` + +GetRoleOk returns a tuple with the Role field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetRole + +`func (o *GatewayAccessGrantRequest) SetRole(v GatewayAccessRole)` + +SetRole sets Role field to given value. + + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/components/api-server/pkg/api/openapi/docs/GatewayAccessGrantResponse.md b/components/api-server/pkg/api/openapi/docs/GatewayAccessGrantResponse.md new file mode 100644 index 000000000..4afee4503 --- /dev/null +++ b/components/api-server/pkg/api/openapi/docs/GatewayAccessGrantResponse.md @@ -0,0 +1,228 @@ +# GatewayAccessGrantResponse + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**RoleBindingId** | **string** | | +**UserId** | **string** | | +**Username** | **string** | | +**Name** | Pointer to **NullableString** | | [optional] +**Email** | Pointer to **NullableString** | | [optional] +**Role** | [**GatewayAccessRole**](GatewayAccessRole.md) | | +**IsCreator** | **bool** | | +**GrantedAt** | **time.Time** | | + +## Methods + +### NewGatewayAccessGrantResponse + +`func NewGatewayAccessGrantResponse(roleBindingId string, userId string, username string, role GatewayAccessRole, isCreator bool, grantedAt time.Time, ) *GatewayAccessGrantResponse` + +NewGatewayAccessGrantResponse instantiates a new GatewayAccessGrantResponse object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewGatewayAccessGrantResponseWithDefaults + +`func NewGatewayAccessGrantResponseWithDefaults() *GatewayAccessGrantResponse` + +NewGatewayAccessGrantResponseWithDefaults instantiates a new GatewayAccessGrantResponse object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetRoleBindingId + +`func (o *GatewayAccessGrantResponse) GetRoleBindingId() string` + +GetRoleBindingId returns the RoleBindingId field if non-nil, zero value otherwise. + +### GetRoleBindingIdOk + +`func (o *GatewayAccessGrantResponse) GetRoleBindingIdOk() (*string, bool)` + +GetRoleBindingIdOk returns a tuple with the RoleBindingId field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetRoleBindingId + +`func (o *GatewayAccessGrantResponse) SetRoleBindingId(v string)` + +SetRoleBindingId sets RoleBindingId field to given value. + + +### GetUserId + +`func (o *GatewayAccessGrantResponse) GetUserId() string` + +GetUserId returns the UserId field if non-nil, zero value otherwise. + +### GetUserIdOk + +`func (o *GatewayAccessGrantResponse) GetUserIdOk() (*string, bool)` + +GetUserIdOk returns a tuple with the UserId field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetUserId + +`func (o *GatewayAccessGrantResponse) SetUserId(v string)` + +SetUserId sets UserId field to given value. + + +### GetUsername + +`func (o *GatewayAccessGrantResponse) GetUsername() string` + +GetUsername returns the Username field if non-nil, zero value otherwise. + +### GetUsernameOk + +`func (o *GatewayAccessGrantResponse) GetUsernameOk() (*string, bool)` + +GetUsernameOk returns a tuple with the Username field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetUsername + +`func (o *GatewayAccessGrantResponse) SetUsername(v string)` + +SetUsername sets Username field to given value. + + +### GetName + +`func (o *GatewayAccessGrantResponse) GetName() string` + +GetName returns the Name field if non-nil, zero value otherwise. + +### GetNameOk + +`func (o *GatewayAccessGrantResponse) GetNameOk() (*string, bool)` + +GetNameOk returns a tuple with the Name field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetName + +`func (o *GatewayAccessGrantResponse) SetName(v string)` + +SetName sets Name field to given value. + +### HasName + +`func (o *GatewayAccessGrantResponse) HasName() bool` + +HasName returns a boolean if a field has been set. + +### SetNameNil + +`func (o *GatewayAccessGrantResponse) SetNameNil(b bool)` + + SetNameNil sets the value for Name to be an explicit nil + +### UnsetName +`func (o *GatewayAccessGrantResponse) UnsetName()` + +UnsetName ensures that no value is present for Name, not even an explicit nil +### GetEmail + +`func (o *GatewayAccessGrantResponse) GetEmail() string` + +GetEmail returns the Email field if non-nil, zero value otherwise. + +### GetEmailOk + +`func (o *GatewayAccessGrantResponse) GetEmailOk() (*string, bool)` + +GetEmailOk returns a tuple with the Email field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetEmail + +`func (o *GatewayAccessGrantResponse) SetEmail(v string)` + +SetEmail sets Email field to given value. + +### HasEmail + +`func (o *GatewayAccessGrantResponse) HasEmail() bool` + +HasEmail returns a boolean if a field has been set. + +### SetEmailNil + +`func (o *GatewayAccessGrantResponse) SetEmailNil(b bool)` + + SetEmailNil sets the value for Email to be an explicit nil + +### UnsetEmail +`func (o *GatewayAccessGrantResponse) UnsetEmail()` + +UnsetEmail ensures that no value is present for Email, not even an explicit nil +### GetRole + +`func (o *GatewayAccessGrantResponse) GetRole() GatewayAccessRole` + +GetRole returns the Role field if non-nil, zero value otherwise. + +### GetRoleOk + +`func (o *GatewayAccessGrantResponse) GetRoleOk() (*GatewayAccessRole, bool)` + +GetRoleOk returns a tuple with the Role field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetRole + +`func (o *GatewayAccessGrantResponse) SetRole(v GatewayAccessRole)` + +SetRole sets Role field to given value. + + +### GetIsCreator + +`func (o *GatewayAccessGrantResponse) GetIsCreator() bool` + +GetIsCreator returns the IsCreator field if non-nil, zero value otherwise. + +### GetIsCreatorOk + +`func (o *GatewayAccessGrantResponse) GetIsCreatorOk() (*bool, bool)` + +GetIsCreatorOk returns a tuple with the IsCreator field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetIsCreator + +`func (o *GatewayAccessGrantResponse) SetIsCreator(v bool)` + +SetIsCreator sets IsCreator field to given value. + + +### GetGrantedAt + +`func (o *GatewayAccessGrantResponse) GetGrantedAt() time.Time` + +GetGrantedAt returns the GrantedAt field if non-nil, zero value otherwise. + +### GetGrantedAtOk + +`func (o *GatewayAccessGrantResponse) GetGrantedAtOk() (*time.Time, bool)` + +GetGrantedAtOk returns a tuple with the GrantedAt field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetGrantedAt + +`func (o *GatewayAccessGrantResponse) SetGrantedAt(v time.Time)` + +SetGrantedAt sets GrantedAt field to given value. + + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/components/api-server/pkg/api/openapi/docs/GatewayAccessList.md b/components/api-server/pkg/api/openapi/docs/GatewayAccessList.md new file mode 100644 index 000000000..60b7851d9 --- /dev/null +++ b/components/api-server/pkg/api/openapi/docs/GatewayAccessList.md @@ -0,0 +1,135 @@ +# GatewayAccessList + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Page** | **int32** | | +**Size** | **int32** | | +**Total** | **int32** | | +**Capabilities** | [**GatewayAccessCapabilities**](GatewayAccessCapabilities.md) | | +**Items** | [**[]GatewayAccessListItem**](GatewayAccessListItem.md) | | + +## Methods + +### NewGatewayAccessList + +`func NewGatewayAccessList(page int32, size int32, total int32, capabilities GatewayAccessCapabilities, items []GatewayAccessListItem, ) *GatewayAccessList` + +NewGatewayAccessList instantiates a new GatewayAccessList object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewGatewayAccessListWithDefaults + +`func NewGatewayAccessListWithDefaults() *GatewayAccessList` + +NewGatewayAccessListWithDefaults instantiates a new GatewayAccessList object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetPage + +`func (o *GatewayAccessList) GetPage() int32` + +GetPage returns the Page field if non-nil, zero value otherwise. + +### GetPageOk + +`func (o *GatewayAccessList) GetPageOk() (*int32, bool)` + +GetPageOk returns a tuple with the Page field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetPage + +`func (o *GatewayAccessList) SetPage(v int32)` + +SetPage sets Page field to given value. + + +### GetSize + +`func (o *GatewayAccessList) GetSize() int32` + +GetSize returns the Size field if non-nil, zero value otherwise. + +### GetSizeOk + +`func (o *GatewayAccessList) GetSizeOk() (*int32, bool)` + +GetSizeOk returns a tuple with the Size field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetSize + +`func (o *GatewayAccessList) SetSize(v int32)` + +SetSize sets Size field to given value. + + +### GetTotal + +`func (o *GatewayAccessList) GetTotal() int32` + +GetTotal returns the Total field if non-nil, zero value otherwise. + +### GetTotalOk + +`func (o *GatewayAccessList) GetTotalOk() (*int32, bool)` + +GetTotalOk returns a tuple with the Total field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetTotal + +`func (o *GatewayAccessList) SetTotal(v int32)` + +SetTotal sets Total field to given value. + + +### GetCapabilities + +`func (o *GatewayAccessList) GetCapabilities() GatewayAccessCapabilities` + +GetCapabilities returns the Capabilities field if non-nil, zero value otherwise. + +### GetCapabilitiesOk + +`func (o *GatewayAccessList) GetCapabilitiesOk() (*GatewayAccessCapabilities, bool)` + +GetCapabilitiesOk returns a tuple with the Capabilities field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetCapabilities + +`func (o *GatewayAccessList) SetCapabilities(v GatewayAccessCapabilities)` + +SetCapabilities sets Capabilities field to given value. + + +### GetItems + +`func (o *GatewayAccessList) GetItems() []GatewayAccessListItem` + +GetItems returns the Items field if non-nil, zero value otherwise. + +### GetItemsOk + +`func (o *GatewayAccessList) GetItemsOk() (*[]GatewayAccessListItem, bool)` + +GetItemsOk returns a tuple with the Items field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetItems + +`func (o *GatewayAccessList) SetItems(v []GatewayAccessListItem)` + +SetItems sets Items field to given value. + + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/components/api-server/pkg/api/openapi/docs/GatewayAccessListItem.md b/components/api-server/pkg/api/openapi/docs/GatewayAccessListItem.md new file mode 100644 index 000000000..642553a2a --- /dev/null +++ b/components/api-server/pkg/api/openapi/docs/GatewayAccessListItem.md @@ -0,0 +1,228 @@ +# GatewayAccessListItem + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**RoleBindingId** | **string** | | +**UserId** | **string** | | +**Username** | **string** | | +**Name** | Pointer to **NullableString** | | [optional] +**Email** | Pointer to **NullableString** | | [optional] +**Role** | [**GatewayAccessRole**](GatewayAccessRole.md) | | +**IsCreator** | **bool** | | +**GrantedAt** | **time.Time** | | + +## Methods + +### NewGatewayAccessListItem + +`func NewGatewayAccessListItem(roleBindingId string, userId string, username string, role GatewayAccessRole, isCreator bool, grantedAt time.Time, ) *GatewayAccessListItem` + +NewGatewayAccessListItem instantiates a new GatewayAccessListItem object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewGatewayAccessListItemWithDefaults + +`func NewGatewayAccessListItemWithDefaults() *GatewayAccessListItem` + +NewGatewayAccessListItemWithDefaults instantiates a new GatewayAccessListItem object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetRoleBindingId + +`func (o *GatewayAccessListItem) GetRoleBindingId() string` + +GetRoleBindingId returns the RoleBindingId field if non-nil, zero value otherwise. + +### GetRoleBindingIdOk + +`func (o *GatewayAccessListItem) GetRoleBindingIdOk() (*string, bool)` + +GetRoleBindingIdOk returns a tuple with the RoleBindingId field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetRoleBindingId + +`func (o *GatewayAccessListItem) SetRoleBindingId(v string)` + +SetRoleBindingId sets RoleBindingId field to given value. + + +### GetUserId + +`func (o *GatewayAccessListItem) GetUserId() string` + +GetUserId returns the UserId field if non-nil, zero value otherwise. + +### GetUserIdOk + +`func (o *GatewayAccessListItem) GetUserIdOk() (*string, bool)` + +GetUserIdOk returns a tuple with the UserId field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetUserId + +`func (o *GatewayAccessListItem) SetUserId(v string)` + +SetUserId sets UserId field to given value. + + +### GetUsername + +`func (o *GatewayAccessListItem) GetUsername() string` + +GetUsername returns the Username field if non-nil, zero value otherwise. + +### GetUsernameOk + +`func (o *GatewayAccessListItem) GetUsernameOk() (*string, bool)` + +GetUsernameOk returns a tuple with the Username field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetUsername + +`func (o *GatewayAccessListItem) SetUsername(v string)` + +SetUsername sets Username field to given value. + + +### GetName + +`func (o *GatewayAccessListItem) GetName() string` + +GetName returns the Name field if non-nil, zero value otherwise. + +### GetNameOk + +`func (o *GatewayAccessListItem) GetNameOk() (*string, bool)` + +GetNameOk returns a tuple with the Name field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetName + +`func (o *GatewayAccessListItem) SetName(v string)` + +SetName sets Name field to given value. + +### HasName + +`func (o *GatewayAccessListItem) HasName() bool` + +HasName returns a boolean if a field has been set. + +### SetNameNil + +`func (o *GatewayAccessListItem) SetNameNil(b bool)` + + SetNameNil sets the value for Name to be an explicit nil + +### UnsetName +`func (o *GatewayAccessListItem) UnsetName()` + +UnsetName ensures that no value is present for Name, not even an explicit nil +### GetEmail + +`func (o *GatewayAccessListItem) GetEmail() string` + +GetEmail returns the Email field if non-nil, zero value otherwise. + +### GetEmailOk + +`func (o *GatewayAccessListItem) GetEmailOk() (*string, bool)` + +GetEmailOk returns a tuple with the Email field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetEmail + +`func (o *GatewayAccessListItem) SetEmail(v string)` + +SetEmail sets Email field to given value. + +### HasEmail + +`func (o *GatewayAccessListItem) HasEmail() bool` + +HasEmail returns a boolean if a field has been set. + +### SetEmailNil + +`func (o *GatewayAccessListItem) SetEmailNil(b bool)` + + SetEmailNil sets the value for Email to be an explicit nil + +### UnsetEmail +`func (o *GatewayAccessListItem) UnsetEmail()` + +UnsetEmail ensures that no value is present for Email, not even an explicit nil +### GetRole + +`func (o *GatewayAccessListItem) GetRole() GatewayAccessRole` + +GetRole returns the Role field if non-nil, zero value otherwise. + +### GetRoleOk + +`func (o *GatewayAccessListItem) GetRoleOk() (*GatewayAccessRole, bool)` + +GetRoleOk returns a tuple with the Role field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetRole + +`func (o *GatewayAccessListItem) SetRole(v GatewayAccessRole)` + +SetRole sets Role field to given value. + + +### GetIsCreator + +`func (o *GatewayAccessListItem) GetIsCreator() bool` + +GetIsCreator returns the IsCreator field if non-nil, zero value otherwise. + +### GetIsCreatorOk + +`func (o *GatewayAccessListItem) GetIsCreatorOk() (*bool, bool)` + +GetIsCreatorOk returns a tuple with the IsCreator field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetIsCreator + +`func (o *GatewayAccessListItem) SetIsCreator(v bool)` + +SetIsCreator sets IsCreator field to given value. + + +### GetGrantedAt + +`func (o *GatewayAccessListItem) GetGrantedAt() time.Time` + +GetGrantedAt returns the GrantedAt field if non-nil, zero value otherwise. + +### GetGrantedAtOk + +`func (o *GatewayAccessListItem) GetGrantedAtOk() (*time.Time, bool)` + +GetGrantedAtOk returns a tuple with the GrantedAt field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetGrantedAt + +`func (o *GatewayAccessListItem) SetGrantedAt(v time.Time)` + +SetGrantedAt sets GrantedAt field to given value. + + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/components/api-server/pkg/api/openapi/docs/GatewayAccessRole.md b/components/api-server/pkg/api/openapi/docs/GatewayAccessRole.md new file mode 100644 index 000000000..ae1a6d981 --- /dev/null +++ b/components/api-server/pkg/api/openapi/docs/GatewayAccessRole.md @@ -0,0 +1,15 @@ +# GatewayAccessRole + +## Enum + + +* `OWNER` (value: `"owner"`) + +* `ADMIN` (value: `"admin"`) + +* `USER` (value: `"user"`) + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/components/api-server/pkg/api/openapi/docs/GatewayDirectoryUser.md b/components/api-server/pkg/api/openapi/docs/GatewayDirectoryUser.md new file mode 100644 index 000000000..96f4c5db3 --- /dev/null +++ b/components/api-server/pkg/api/openapi/docs/GatewayDirectoryUser.md @@ -0,0 +1,159 @@ +# GatewayDirectoryUser + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Username** | **string** | | +**Name** | Pointer to **NullableString** | | [optional] +**Email** | Pointer to **NullableString** | | [optional] +**Subject** | Pointer to **NullableString** | | [optional] + +## Methods + +### NewGatewayDirectoryUser + +`func NewGatewayDirectoryUser(username string, ) *GatewayDirectoryUser` + +NewGatewayDirectoryUser instantiates a new GatewayDirectoryUser object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewGatewayDirectoryUserWithDefaults + +`func NewGatewayDirectoryUserWithDefaults() *GatewayDirectoryUser` + +NewGatewayDirectoryUserWithDefaults instantiates a new GatewayDirectoryUser object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetUsername + +`func (o *GatewayDirectoryUser) GetUsername() string` + +GetUsername returns the Username field if non-nil, zero value otherwise. + +### GetUsernameOk + +`func (o *GatewayDirectoryUser) GetUsernameOk() (*string, bool)` + +GetUsernameOk returns a tuple with the Username field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetUsername + +`func (o *GatewayDirectoryUser) SetUsername(v string)` + +SetUsername sets Username field to given value. + + +### GetName + +`func (o *GatewayDirectoryUser) GetName() string` + +GetName returns the Name field if non-nil, zero value otherwise. + +### GetNameOk + +`func (o *GatewayDirectoryUser) GetNameOk() (*string, bool)` + +GetNameOk returns a tuple with the Name field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetName + +`func (o *GatewayDirectoryUser) SetName(v string)` + +SetName sets Name field to given value. + +### HasName + +`func (o *GatewayDirectoryUser) HasName() bool` + +HasName returns a boolean if a field has been set. + +### SetNameNil + +`func (o *GatewayDirectoryUser) SetNameNil(b bool)` + + SetNameNil sets the value for Name to be an explicit nil + +### UnsetName +`func (o *GatewayDirectoryUser) UnsetName()` + +UnsetName ensures that no value is present for Name, not even an explicit nil +### GetEmail + +`func (o *GatewayDirectoryUser) GetEmail() string` + +GetEmail returns the Email field if non-nil, zero value otherwise. + +### GetEmailOk + +`func (o *GatewayDirectoryUser) GetEmailOk() (*string, bool)` + +GetEmailOk returns a tuple with the Email field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetEmail + +`func (o *GatewayDirectoryUser) SetEmail(v string)` + +SetEmail sets Email field to given value. + +### HasEmail + +`func (o *GatewayDirectoryUser) HasEmail() bool` + +HasEmail returns a boolean if a field has been set. + +### SetEmailNil + +`func (o *GatewayDirectoryUser) SetEmailNil(b bool)` + + SetEmailNil sets the value for Email to be an explicit nil + +### UnsetEmail +`func (o *GatewayDirectoryUser) UnsetEmail()` + +UnsetEmail ensures that no value is present for Email, not even an explicit nil +### GetSubject + +`func (o *GatewayDirectoryUser) GetSubject() string` + +GetSubject returns the Subject field if non-nil, zero value otherwise. + +### GetSubjectOk + +`func (o *GatewayDirectoryUser) GetSubjectOk() (*string, bool)` + +GetSubjectOk returns a tuple with the Subject field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetSubject + +`func (o *GatewayDirectoryUser) SetSubject(v string)` + +SetSubject sets Subject field to given value. + +### HasSubject + +`func (o *GatewayDirectoryUser) HasSubject() bool` + +HasSubject returns a boolean if a field has been set. + +### SetSubjectNil + +`func (o *GatewayDirectoryUser) SetSubjectNil(b bool)` + + SetSubjectNil sets the value for Subject to be an explicit nil + +### UnsetSubject +`func (o *GatewayDirectoryUser) UnsetSubject()` + +UnsetSubject ensures that no value is present for Subject, not even an explicit nil + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/components/api-server/pkg/api/openapi/docs/GatewayDirectoryUserList.md b/components/api-server/pkg/api/openapi/docs/GatewayDirectoryUserList.md new file mode 100644 index 000000000..65bfc6c41 --- /dev/null +++ b/components/api-server/pkg/api/openapi/docs/GatewayDirectoryUserList.md @@ -0,0 +1,51 @@ +# GatewayDirectoryUserList + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Items** | [**[]GatewayDirectoryUser**](GatewayDirectoryUser.md) | | + +## Methods + +### NewGatewayDirectoryUserList + +`func NewGatewayDirectoryUserList(items []GatewayDirectoryUser, ) *GatewayDirectoryUserList` + +NewGatewayDirectoryUserList instantiates a new GatewayDirectoryUserList object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewGatewayDirectoryUserListWithDefaults + +`func NewGatewayDirectoryUserListWithDefaults() *GatewayDirectoryUserList` + +NewGatewayDirectoryUserListWithDefaults instantiates a new GatewayDirectoryUserList object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetItems + +`func (o *GatewayDirectoryUserList) GetItems() []GatewayDirectoryUser` + +GetItems returns the Items field if non-nil, zero value otherwise. + +### GetItemsOk + +`func (o *GatewayDirectoryUserList) GetItemsOk() (*[]GatewayDirectoryUser, bool)` + +GetItemsOk returns a tuple with the Items field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetItems + +`func (o *GatewayDirectoryUserList) SetItems(v []GatewayDirectoryUser)` + +SetItems sets Items field to given value. + + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/components/api-server/pkg/api/openapi/model_gateway_access_capabilities.go b/components/api-server/pkg/api/openapi/model_gateway_access_capabilities.go new file mode 100644 index 000000000..5241ff25d --- /dev/null +++ b/components/api-server/pkg/api/openapi/model_gateway_access_capabilities.go @@ -0,0 +1,220 @@ +/* +HyperShell API + +HyperShell gateway management API + +API version: 1.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the GatewayAccessCapabilities type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &GatewayAccessCapabilities{} + +// GatewayAccessCapabilities struct for GatewayAccessCapabilities +type GatewayAccessCapabilities struct { + CallerRole *GatewayAccessRole `json:"caller_role,omitempty"` + CanManageAccess bool `json:"can_manage_access"` + CanManageOwners bool `json:"can_manage_owners"` +} + +type _GatewayAccessCapabilities GatewayAccessCapabilities + +// NewGatewayAccessCapabilities instantiates a new GatewayAccessCapabilities object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewGatewayAccessCapabilities(canManageAccess bool, canManageOwners bool) *GatewayAccessCapabilities { + this := GatewayAccessCapabilities{} + this.CanManageAccess = canManageAccess + this.CanManageOwners = canManageOwners + return &this +} + +// NewGatewayAccessCapabilitiesWithDefaults instantiates a new GatewayAccessCapabilities object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewGatewayAccessCapabilitiesWithDefaults() *GatewayAccessCapabilities { + this := GatewayAccessCapabilities{} + return &this +} + +// GetCallerRole returns the CallerRole field value if set, zero value otherwise. +func (o *GatewayAccessCapabilities) GetCallerRole() GatewayAccessRole { + if o == nil || IsNil(o.CallerRole) { + var ret GatewayAccessRole + return ret + } + return *o.CallerRole +} + +// GetCallerRoleOk returns a tuple with the CallerRole field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *GatewayAccessCapabilities) GetCallerRoleOk() (*GatewayAccessRole, bool) { + if o == nil || IsNil(o.CallerRole) { + return nil, false + } + return o.CallerRole, true +} + +// HasCallerRole returns a boolean if a field has been set. +func (o *GatewayAccessCapabilities) HasCallerRole() bool { + if o != nil && !IsNil(o.CallerRole) { + return true + } + + return false +} + +// SetCallerRole gets a reference to the given GatewayAccessRole and assigns it to the CallerRole field. +func (o *GatewayAccessCapabilities) SetCallerRole(v GatewayAccessRole) { + o.CallerRole = &v +} + +// GetCanManageAccess returns the CanManageAccess field value +func (o *GatewayAccessCapabilities) GetCanManageAccess() bool { + if o == nil { + var ret bool + return ret + } + + return o.CanManageAccess +} + +// GetCanManageAccessOk returns a tuple with the CanManageAccess field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessCapabilities) GetCanManageAccessOk() (*bool, bool) { + if o == nil { + return nil, false + } + return &o.CanManageAccess, true +} + +// SetCanManageAccess sets field value +func (o *GatewayAccessCapabilities) SetCanManageAccess(v bool) { + o.CanManageAccess = v +} + +// GetCanManageOwners returns the CanManageOwners field value +func (o *GatewayAccessCapabilities) GetCanManageOwners() bool { + if o == nil { + var ret bool + return ret + } + + return o.CanManageOwners +} + +// GetCanManageOwnersOk returns a tuple with the CanManageOwners field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessCapabilities) GetCanManageOwnersOk() (*bool, bool) { + if o == nil { + return nil, false + } + return &o.CanManageOwners, true +} + +// SetCanManageOwners sets field value +func (o *GatewayAccessCapabilities) SetCanManageOwners(v bool) { + o.CanManageOwners = v +} + +func (o GatewayAccessCapabilities) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o GatewayAccessCapabilities) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.CallerRole) { + toSerialize["caller_role"] = o.CallerRole + } + toSerialize["can_manage_access"] = o.CanManageAccess + toSerialize["can_manage_owners"] = o.CanManageOwners + return toSerialize, nil +} + +func (o *GatewayAccessCapabilities) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "can_manage_access", + "can_manage_owners", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varGatewayAccessCapabilities := _GatewayAccessCapabilities{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varGatewayAccessCapabilities) + + if err != nil { + return err + } + + *o = GatewayAccessCapabilities(varGatewayAccessCapabilities) + + return err +} + +type NullableGatewayAccessCapabilities struct { + value *GatewayAccessCapabilities + isSet bool +} + +func (v NullableGatewayAccessCapabilities) Get() *GatewayAccessCapabilities { + return v.value +} + +func (v *NullableGatewayAccessCapabilities) Set(val *GatewayAccessCapabilities) { + v.value = val + v.isSet = true +} + +func (v NullableGatewayAccessCapabilities) IsSet() bool { + return v.isSet +} + +func (v *NullableGatewayAccessCapabilities) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableGatewayAccessCapabilities(val *GatewayAccessCapabilities) *NullableGatewayAccessCapabilities { + return &NullableGatewayAccessCapabilities{value: val, isSet: true} +} + +func (v NullableGatewayAccessCapabilities) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableGatewayAccessCapabilities) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/components/api-server/pkg/api/openapi/model_gateway_access_change_role_request.go b/components/api-server/pkg/api/openapi/model_gateway_access_change_role_request.go new file mode 100644 index 000000000..4645196c9 --- /dev/null +++ b/components/api-server/pkg/api/openapi/model_gateway_access_change_role_request.go @@ -0,0 +1,156 @@ +/* +HyperShell API + +HyperShell gateway management API + +API version: 1.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the GatewayAccessChangeRoleRequest type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &GatewayAccessChangeRoleRequest{} + +// GatewayAccessChangeRoleRequest struct for GatewayAccessChangeRoleRequest +type GatewayAccessChangeRoleRequest struct { + Role GatewayAccessRole `json:"role"` +} + +type _GatewayAccessChangeRoleRequest GatewayAccessChangeRoleRequest + +// NewGatewayAccessChangeRoleRequest instantiates a new GatewayAccessChangeRoleRequest object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewGatewayAccessChangeRoleRequest(role GatewayAccessRole) *GatewayAccessChangeRoleRequest { + this := GatewayAccessChangeRoleRequest{} + this.Role = role + return &this +} + +// NewGatewayAccessChangeRoleRequestWithDefaults instantiates a new GatewayAccessChangeRoleRequest object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewGatewayAccessChangeRoleRequestWithDefaults() *GatewayAccessChangeRoleRequest { + this := GatewayAccessChangeRoleRequest{} + return &this +} + +// GetRole returns the Role field value +func (o *GatewayAccessChangeRoleRequest) GetRole() GatewayAccessRole { + if o == nil { + var ret GatewayAccessRole + return ret + } + + return o.Role +} + +// GetRoleOk returns a tuple with the Role field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessChangeRoleRequest) GetRoleOk() (*GatewayAccessRole, bool) { + if o == nil { + return nil, false + } + return &o.Role, true +} + +// SetRole sets field value +func (o *GatewayAccessChangeRoleRequest) SetRole(v GatewayAccessRole) { + o.Role = v +} + +func (o GatewayAccessChangeRoleRequest) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o GatewayAccessChangeRoleRequest) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["role"] = o.Role + return toSerialize, nil +} + +func (o *GatewayAccessChangeRoleRequest) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "role", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varGatewayAccessChangeRoleRequest := _GatewayAccessChangeRoleRequest{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varGatewayAccessChangeRoleRequest) + + if err != nil { + return err + } + + *o = GatewayAccessChangeRoleRequest(varGatewayAccessChangeRoleRequest) + + return err +} + +type NullableGatewayAccessChangeRoleRequest struct { + value *GatewayAccessChangeRoleRequest + isSet bool +} + +func (v NullableGatewayAccessChangeRoleRequest) Get() *GatewayAccessChangeRoleRequest { + return v.value +} + +func (v *NullableGatewayAccessChangeRoleRequest) Set(val *GatewayAccessChangeRoleRequest) { + v.value = val + v.isSet = true +} + +func (v NullableGatewayAccessChangeRoleRequest) IsSet() bool { + return v.isSet +} + +func (v *NullableGatewayAccessChangeRoleRequest) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableGatewayAccessChangeRoleRequest(val *GatewayAccessChangeRoleRequest) *NullableGatewayAccessChangeRoleRequest { + return &NullableGatewayAccessChangeRoleRequest{value: val, isSet: true} +} + +func (v NullableGatewayAccessChangeRoleRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableGatewayAccessChangeRoleRequest) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/components/api-server/pkg/api/openapi/model_gateway_access_grant_request.go b/components/api-server/pkg/api/openapi/model_gateway_access_grant_request.go new file mode 100644 index 000000000..3c69f6094 --- /dev/null +++ b/components/api-server/pkg/api/openapi/model_gateway_access_grant_request.go @@ -0,0 +1,221 @@ +/* +HyperShell API + +HyperShell gateway management API + +API version: 1.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the GatewayAccessGrantRequest type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &GatewayAccessGrantRequest{} + +// GatewayAccessGrantRequest struct for GatewayAccessGrantRequest +type GatewayAccessGrantRequest struct { + Username string `json:"username"` + // Optional Keycloak subject reference returned by the directory search + Subject *string `json:"subject,omitempty"` + Role GatewayAccessRole `json:"role"` +} + +type _GatewayAccessGrantRequest GatewayAccessGrantRequest + +// NewGatewayAccessGrantRequest instantiates a new GatewayAccessGrantRequest object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewGatewayAccessGrantRequest(username string, role GatewayAccessRole) *GatewayAccessGrantRequest { + this := GatewayAccessGrantRequest{} + this.Username = username + this.Role = role + return &this +} + +// NewGatewayAccessGrantRequestWithDefaults instantiates a new GatewayAccessGrantRequest object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewGatewayAccessGrantRequestWithDefaults() *GatewayAccessGrantRequest { + this := GatewayAccessGrantRequest{} + return &this +} + +// GetUsername returns the Username field value +func (o *GatewayAccessGrantRequest) GetUsername() string { + if o == nil { + var ret string + return ret + } + + return o.Username +} + +// GetUsernameOk returns a tuple with the Username field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessGrantRequest) GetUsernameOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Username, true +} + +// SetUsername sets field value +func (o *GatewayAccessGrantRequest) SetUsername(v string) { + o.Username = v +} + +// GetSubject returns the Subject field value if set, zero value otherwise. +func (o *GatewayAccessGrantRequest) GetSubject() string { + if o == nil || IsNil(o.Subject) { + var ret string + return ret + } + return *o.Subject +} + +// GetSubjectOk returns a tuple with the Subject field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *GatewayAccessGrantRequest) GetSubjectOk() (*string, bool) { + if o == nil || IsNil(o.Subject) { + return nil, false + } + return o.Subject, true +} + +// HasSubject returns a boolean if a field has been set. +func (o *GatewayAccessGrantRequest) HasSubject() bool { + if o != nil && !IsNil(o.Subject) { + return true + } + + return false +} + +// SetSubject gets a reference to the given string and assigns it to the Subject field. +func (o *GatewayAccessGrantRequest) SetSubject(v string) { + o.Subject = &v +} + +// GetRole returns the Role field value +func (o *GatewayAccessGrantRequest) GetRole() GatewayAccessRole { + if o == nil { + var ret GatewayAccessRole + return ret + } + + return o.Role +} + +// GetRoleOk returns a tuple with the Role field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessGrantRequest) GetRoleOk() (*GatewayAccessRole, bool) { + if o == nil { + return nil, false + } + return &o.Role, true +} + +// SetRole sets field value +func (o *GatewayAccessGrantRequest) SetRole(v GatewayAccessRole) { + o.Role = v +} + +func (o GatewayAccessGrantRequest) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o GatewayAccessGrantRequest) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["username"] = o.Username + if !IsNil(o.Subject) { + toSerialize["subject"] = o.Subject + } + toSerialize["role"] = o.Role + return toSerialize, nil +} + +func (o *GatewayAccessGrantRequest) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "username", + "role", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varGatewayAccessGrantRequest := _GatewayAccessGrantRequest{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varGatewayAccessGrantRequest) + + if err != nil { + return err + } + + *o = GatewayAccessGrantRequest(varGatewayAccessGrantRequest) + + return err +} + +type NullableGatewayAccessGrantRequest struct { + value *GatewayAccessGrantRequest + isSet bool +} + +func (v NullableGatewayAccessGrantRequest) Get() *GatewayAccessGrantRequest { + return v.value +} + +func (v *NullableGatewayAccessGrantRequest) Set(val *GatewayAccessGrantRequest) { + v.value = val + v.isSet = true +} + +func (v NullableGatewayAccessGrantRequest) IsSet() bool { + return v.isSet +} + +func (v *NullableGatewayAccessGrantRequest) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableGatewayAccessGrantRequest(val *GatewayAccessGrantRequest) *NullableGatewayAccessGrantRequest { + return &NullableGatewayAccessGrantRequest{value: val, isSet: true} +} + +func (v NullableGatewayAccessGrantRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableGatewayAccessGrantRequest) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/components/api-server/pkg/api/openapi/model_gateway_access_grant_response.go b/components/api-server/pkg/api/openapi/model_gateway_access_grant_response.go new file mode 100644 index 000000000..0bdf8d9ec --- /dev/null +++ b/components/api-server/pkg/api/openapi/model_gateway_access_grant_response.go @@ -0,0 +1,391 @@ +/* +HyperShell API + +HyperShell gateway management API + +API version: 1.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "bytes" + "encoding/json" + "fmt" + "time" +) + +// checks if the GatewayAccessGrantResponse type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &GatewayAccessGrantResponse{} + +// GatewayAccessGrantResponse struct for GatewayAccessGrantResponse +type GatewayAccessGrantResponse struct { + RoleBindingId string `json:"role_binding_id"` + UserId string `json:"user_id"` + Username string `json:"username"` + Name NullableString `json:"name,omitempty"` + Email NullableString `json:"email,omitempty"` + Role GatewayAccessRole `json:"role"` + IsCreator bool `json:"is_creator"` + GrantedAt time.Time `json:"granted_at"` +} + +type _GatewayAccessGrantResponse GatewayAccessGrantResponse + +// NewGatewayAccessGrantResponse instantiates a new GatewayAccessGrantResponse object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewGatewayAccessGrantResponse(roleBindingId string, userId string, username string, role GatewayAccessRole, isCreator bool, grantedAt time.Time) *GatewayAccessGrantResponse { + this := GatewayAccessGrantResponse{} + this.RoleBindingId = roleBindingId + this.UserId = userId + this.Username = username + this.Role = role + this.IsCreator = isCreator + this.GrantedAt = grantedAt + return &this +} + +// NewGatewayAccessGrantResponseWithDefaults instantiates a new GatewayAccessGrantResponse object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewGatewayAccessGrantResponseWithDefaults() *GatewayAccessGrantResponse { + this := GatewayAccessGrantResponse{} + return &this +} + +// GetRoleBindingId returns the RoleBindingId field value +func (o *GatewayAccessGrantResponse) GetRoleBindingId() string { + if o == nil { + var ret string + return ret + } + + return o.RoleBindingId +} + +// GetRoleBindingIdOk returns a tuple with the RoleBindingId field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessGrantResponse) GetRoleBindingIdOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.RoleBindingId, true +} + +// SetRoleBindingId sets field value +func (o *GatewayAccessGrantResponse) SetRoleBindingId(v string) { + o.RoleBindingId = v +} + +// GetUserId returns the UserId field value +func (o *GatewayAccessGrantResponse) GetUserId() string { + if o == nil { + var ret string + return ret + } + + return o.UserId +} + +// GetUserIdOk returns a tuple with the UserId field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessGrantResponse) GetUserIdOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.UserId, true +} + +// SetUserId sets field value +func (o *GatewayAccessGrantResponse) SetUserId(v string) { + o.UserId = v +} + +// GetUsername returns the Username field value +func (o *GatewayAccessGrantResponse) GetUsername() string { + if o == nil { + var ret string + return ret + } + + return o.Username +} + +// GetUsernameOk returns a tuple with the Username field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessGrantResponse) GetUsernameOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Username, true +} + +// SetUsername sets field value +func (o *GatewayAccessGrantResponse) SetUsername(v string) { + o.Username = v +} + +// GetName returns the Name field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *GatewayAccessGrantResponse) GetName() string { + if o == nil || IsNil(o.Name.Get()) { + var ret string + return ret + } + return *o.Name.Get() +} + +// GetNameOk returns a tuple with the Name field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *GatewayAccessGrantResponse) GetNameOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Name.Get(), o.Name.IsSet() +} + +// HasName returns a boolean if a field has been set. +func (o *GatewayAccessGrantResponse) HasName() bool { + if o != nil && o.Name.IsSet() { + return true + } + + return false +} + +// SetName gets a reference to the given NullableString and assigns it to the Name field. +func (o *GatewayAccessGrantResponse) SetName(v string) { + o.Name.Set(&v) +} + +// SetNameNil sets the value for Name to be an explicit nil +func (o *GatewayAccessGrantResponse) SetNameNil() { + o.Name.Set(nil) +} + +// UnsetName ensures that no value is present for Name, not even an explicit nil +func (o *GatewayAccessGrantResponse) UnsetName() { + o.Name.Unset() +} + +// GetEmail returns the Email field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *GatewayAccessGrantResponse) GetEmail() string { + if o == nil || IsNil(o.Email.Get()) { + var ret string + return ret + } + return *o.Email.Get() +} + +// GetEmailOk returns a tuple with the Email field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *GatewayAccessGrantResponse) GetEmailOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Email.Get(), o.Email.IsSet() +} + +// HasEmail returns a boolean if a field has been set. +func (o *GatewayAccessGrantResponse) HasEmail() bool { + if o != nil && o.Email.IsSet() { + return true + } + + return false +} + +// SetEmail gets a reference to the given NullableString and assigns it to the Email field. +func (o *GatewayAccessGrantResponse) SetEmail(v string) { + o.Email.Set(&v) +} + +// SetEmailNil sets the value for Email to be an explicit nil +func (o *GatewayAccessGrantResponse) SetEmailNil() { + o.Email.Set(nil) +} + +// UnsetEmail ensures that no value is present for Email, not even an explicit nil +func (o *GatewayAccessGrantResponse) UnsetEmail() { + o.Email.Unset() +} + +// GetRole returns the Role field value +func (o *GatewayAccessGrantResponse) GetRole() GatewayAccessRole { + if o == nil { + var ret GatewayAccessRole + return ret + } + + return o.Role +} + +// GetRoleOk returns a tuple with the Role field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessGrantResponse) GetRoleOk() (*GatewayAccessRole, bool) { + if o == nil { + return nil, false + } + return &o.Role, true +} + +// SetRole sets field value +func (o *GatewayAccessGrantResponse) SetRole(v GatewayAccessRole) { + o.Role = v +} + +// GetIsCreator returns the IsCreator field value +func (o *GatewayAccessGrantResponse) GetIsCreator() bool { + if o == nil { + var ret bool + return ret + } + + return o.IsCreator +} + +// GetIsCreatorOk returns a tuple with the IsCreator field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessGrantResponse) GetIsCreatorOk() (*bool, bool) { + if o == nil { + return nil, false + } + return &o.IsCreator, true +} + +// SetIsCreator sets field value +func (o *GatewayAccessGrantResponse) SetIsCreator(v bool) { + o.IsCreator = v +} + +// GetGrantedAt returns the GrantedAt field value +func (o *GatewayAccessGrantResponse) GetGrantedAt() time.Time { + if o == nil { + var ret time.Time + return ret + } + + return o.GrantedAt +} + +// GetGrantedAtOk returns a tuple with the GrantedAt field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessGrantResponse) GetGrantedAtOk() (*time.Time, bool) { + if o == nil { + return nil, false + } + return &o.GrantedAt, true +} + +// SetGrantedAt sets field value +func (o *GatewayAccessGrantResponse) SetGrantedAt(v time.Time) { + o.GrantedAt = v +} + +func (o GatewayAccessGrantResponse) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o GatewayAccessGrantResponse) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["role_binding_id"] = o.RoleBindingId + toSerialize["user_id"] = o.UserId + toSerialize["username"] = o.Username + if o.Name.IsSet() { + toSerialize["name"] = o.Name.Get() + } + if o.Email.IsSet() { + toSerialize["email"] = o.Email.Get() + } + toSerialize["role"] = o.Role + toSerialize["is_creator"] = o.IsCreator + toSerialize["granted_at"] = o.GrantedAt + return toSerialize, nil +} + +func (o *GatewayAccessGrantResponse) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "role_binding_id", + "user_id", + "username", + "role", + "is_creator", + "granted_at", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varGatewayAccessGrantResponse := _GatewayAccessGrantResponse{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varGatewayAccessGrantResponse) + + if err != nil { + return err + } + + *o = GatewayAccessGrantResponse(varGatewayAccessGrantResponse) + + return err +} + +type NullableGatewayAccessGrantResponse struct { + value *GatewayAccessGrantResponse + isSet bool +} + +func (v NullableGatewayAccessGrantResponse) Get() *GatewayAccessGrantResponse { + return v.value +} + +func (v *NullableGatewayAccessGrantResponse) Set(val *GatewayAccessGrantResponse) { + v.value = val + v.isSet = true +} + +func (v NullableGatewayAccessGrantResponse) IsSet() bool { + return v.isSet +} + +func (v *NullableGatewayAccessGrantResponse) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableGatewayAccessGrantResponse(val *GatewayAccessGrantResponse) *NullableGatewayAccessGrantResponse { + return &NullableGatewayAccessGrantResponse{value: val, isSet: true} +} + +func (v NullableGatewayAccessGrantResponse) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableGatewayAccessGrantResponse) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/components/api-server/pkg/api/openapi/model_gateway_access_list.go b/components/api-server/pkg/api/openapi/model_gateway_access_list.go new file mode 100644 index 000000000..9cb770431 --- /dev/null +++ b/components/api-server/pkg/api/openapi/model_gateway_access_list.go @@ -0,0 +1,268 @@ +/* +HyperShell API + +HyperShell gateway management API + +API version: 1.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the GatewayAccessList type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &GatewayAccessList{} + +// GatewayAccessList struct for GatewayAccessList +type GatewayAccessList struct { + Page int32 `json:"page"` + Size int32 `json:"size"` + Total int32 `json:"total"` + Capabilities GatewayAccessCapabilities `json:"capabilities"` + Items []GatewayAccessListItem `json:"items"` +} + +type _GatewayAccessList GatewayAccessList + +// NewGatewayAccessList instantiates a new GatewayAccessList object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewGatewayAccessList(page int32, size int32, total int32, capabilities GatewayAccessCapabilities, items []GatewayAccessListItem) *GatewayAccessList { + this := GatewayAccessList{} + this.Page = page + this.Size = size + this.Total = total + this.Capabilities = capabilities + this.Items = items + return &this +} + +// NewGatewayAccessListWithDefaults instantiates a new GatewayAccessList object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewGatewayAccessListWithDefaults() *GatewayAccessList { + this := GatewayAccessList{} + return &this +} + +// GetPage returns the Page field value +func (o *GatewayAccessList) GetPage() int32 { + if o == nil { + var ret int32 + return ret + } + + return o.Page +} + +// GetPageOk returns a tuple with the Page field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessList) GetPageOk() (*int32, bool) { + if o == nil { + return nil, false + } + return &o.Page, true +} + +// SetPage sets field value +func (o *GatewayAccessList) SetPage(v int32) { + o.Page = v +} + +// GetSize returns the Size field value +func (o *GatewayAccessList) GetSize() int32 { + if o == nil { + var ret int32 + return ret + } + + return o.Size +} + +// GetSizeOk returns a tuple with the Size field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessList) GetSizeOk() (*int32, bool) { + if o == nil { + return nil, false + } + return &o.Size, true +} + +// SetSize sets field value +func (o *GatewayAccessList) SetSize(v int32) { + o.Size = v +} + +// GetTotal returns the Total field value +func (o *GatewayAccessList) GetTotal() int32 { + if o == nil { + var ret int32 + return ret + } + + return o.Total +} + +// GetTotalOk returns a tuple with the Total field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessList) GetTotalOk() (*int32, bool) { + if o == nil { + return nil, false + } + return &o.Total, true +} + +// SetTotal sets field value +func (o *GatewayAccessList) SetTotal(v int32) { + o.Total = v +} + +// GetCapabilities returns the Capabilities field value +func (o *GatewayAccessList) GetCapabilities() GatewayAccessCapabilities { + if o == nil { + var ret GatewayAccessCapabilities + return ret + } + + return o.Capabilities +} + +// GetCapabilitiesOk returns a tuple with the Capabilities field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessList) GetCapabilitiesOk() (*GatewayAccessCapabilities, bool) { + if o == nil { + return nil, false + } + return &o.Capabilities, true +} + +// SetCapabilities sets field value +func (o *GatewayAccessList) SetCapabilities(v GatewayAccessCapabilities) { + o.Capabilities = v +} + +// GetItems returns the Items field value +func (o *GatewayAccessList) GetItems() []GatewayAccessListItem { + if o == nil { + var ret []GatewayAccessListItem + return ret + } + + return o.Items +} + +// GetItemsOk returns a tuple with the Items field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessList) GetItemsOk() ([]GatewayAccessListItem, bool) { + if o == nil { + return nil, false + } + return o.Items, true +} + +// SetItems sets field value +func (o *GatewayAccessList) SetItems(v []GatewayAccessListItem) { + o.Items = v +} + +func (o GatewayAccessList) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o GatewayAccessList) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["page"] = o.Page + toSerialize["size"] = o.Size + toSerialize["total"] = o.Total + toSerialize["capabilities"] = o.Capabilities + toSerialize["items"] = o.Items + return toSerialize, nil +} + +func (o *GatewayAccessList) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "page", + "size", + "total", + "capabilities", + "items", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varGatewayAccessList := _GatewayAccessList{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varGatewayAccessList) + + if err != nil { + return err + } + + *o = GatewayAccessList(varGatewayAccessList) + + return err +} + +type NullableGatewayAccessList struct { + value *GatewayAccessList + isSet bool +} + +func (v NullableGatewayAccessList) Get() *GatewayAccessList { + return v.value +} + +func (v *NullableGatewayAccessList) Set(val *GatewayAccessList) { + v.value = val + v.isSet = true +} + +func (v NullableGatewayAccessList) IsSet() bool { + return v.isSet +} + +func (v *NullableGatewayAccessList) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableGatewayAccessList(val *GatewayAccessList) *NullableGatewayAccessList { + return &NullableGatewayAccessList{value: val, isSet: true} +} + +func (v NullableGatewayAccessList) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableGatewayAccessList) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/components/api-server/pkg/api/openapi/model_gateway_access_list_item.go b/components/api-server/pkg/api/openapi/model_gateway_access_list_item.go new file mode 100644 index 000000000..bf0023e46 --- /dev/null +++ b/components/api-server/pkg/api/openapi/model_gateway_access_list_item.go @@ -0,0 +1,391 @@ +/* +HyperShell API + +HyperShell gateway management API + +API version: 1.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "bytes" + "encoding/json" + "fmt" + "time" +) + +// checks if the GatewayAccessListItem type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &GatewayAccessListItem{} + +// GatewayAccessListItem struct for GatewayAccessListItem +type GatewayAccessListItem struct { + RoleBindingId string `json:"role_binding_id"` + UserId string `json:"user_id"` + Username string `json:"username"` + Name NullableString `json:"name,omitempty"` + Email NullableString `json:"email,omitempty"` + Role GatewayAccessRole `json:"role"` + IsCreator bool `json:"is_creator"` + GrantedAt time.Time `json:"granted_at"` +} + +type _GatewayAccessListItem GatewayAccessListItem + +// NewGatewayAccessListItem instantiates a new GatewayAccessListItem object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewGatewayAccessListItem(roleBindingId string, userId string, username string, role GatewayAccessRole, isCreator bool, grantedAt time.Time) *GatewayAccessListItem { + this := GatewayAccessListItem{} + this.RoleBindingId = roleBindingId + this.UserId = userId + this.Username = username + this.Role = role + this.IsCreator = isCreator + this.GrantedAt = grantedAt + return &this +} + +// NewGatewayAccessListItemWithDefaults instantiates a new GatewayAccessListItem object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewGatewayAccessListItemWithDefaults() *GatewayAccessListItem { + this := GatewayAccessListItem{} + return &this +} + +// GetRoleBindingId returns the RoleBindingId field value +func (o *GatewayAccessListItem) GetRoleBindingId() string { + if o == nil { + var ret string + return ret + } + + return o.RoleBindingId +} + +// GetRoleBindingIdOk returns a tuple with the RoleBindingId field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessListItem) GetRoleBindingIdOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.RoleBindingId, true +} + +// SetRoleBindingId sets field value +func (o *GatewayAccessListItem) SetRoleBindingId(v string) { + o.RoleBindingId = v +} + +// GetUserId returns the UserId field value +func (o *GatewayAccessListItem) GetUserId() string { + if o == nil { + var ret string + return ret + } + + return o.UserId +} + +// GetUserIdOk returns a tuple with the UserId field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessListItem) GetUserIdOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.UserId, true +} + +// SetUserId sets field value +func (o *GatewayAccessListItem) SetUserId(v string) { + o.UserId = v +} + +// GetUsername returns the Username field value +func (o *GatewayAccessListItem) GetUsername() string { + if o == nil { + var ret string + return ret + } + + return o.Username +} + +// GetUsernameOk returns a tuple with the Username field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessListItem) GetUsernameOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Username, true +} + +// SetUsername sets field value +func (o *GatewayAccessListItem) SetUsername(v string) { + o.Username = v +} + +// GetName returns the Name field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *GatewayAccessListItem) GetName() string { + if o == nil || IsNil(o.Name.Get()) { + var ret string + return ret + } + return *o.Name.Get() +} + +// GetNameOk returns a tuple with the Name field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *GatewayAccessListItem) GetNameOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Name.Get(), o.Name.IsSet() +} + +// HasName returns a boolean if a field has been set. +func (o *GatewayAccessListItem) HasName() bool { + if o != nil && o.Name.IsSet() { + return true + } + + return false +} + +// SetName gets a reference to the given NullableString and assigns it to the Name field. +func (o *GatewayAccessListItem) SetName(v string) { + o.Name.Set(&v) +} + +// SetNameNil sets the value for Name to be an explicit nil +func (o *GatewayAccessListItem) SetNameNil() { + o.Name.Set(nil) +} + +// UnsetName ensures that no value is present for Name, not even an explicit nil +func (o *GatewayAccessListItem) UnsetName() { + o.Name.Unset() +} + +// GetEmail returns the Email field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *GatewayAccessListItem) GetEmail() string { + if o == nil || IsNil(o.Email.Get()) { + var ret string + return ret + } + return *o.Email.Get() +} + +// GetEmailOk returns a tuple with the Email field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *GatewayAccessListItem) GetEmailOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Email.Get(), o.Email.IsSet() +} + +// HasEmail returns a boolean if a field has been set. +func (o *GatewayAccessListItem) HasEmail() bool { + if o != nil && o.Email.IsSet() { + return true + } + + return false +} + +// SetEmail gets a reference to the given NullableString and assigns it to the Email field. +func (o *GatewayAccessListItem) SetEmail(v string) { + o.Email.Set(&v) +} + +// SetEmailNil sets the value for Email to be an explicit nil +func (o *GatewayAccessListItem) SetEmailNil() { + o.Email.Set(nil) +} + +// UnsetEmail ensures that no value is present for Email, not even an explicit nil +func (o *GatewayAccessListItem) UnsetEmail() { + o.Email.Unset() +} + +// GetRole returns the Role field value +func (o *GatewayAccessListItem) GetRole() GatewayAccessRole { + if o == nil { + var ret GatewayAccessRole + return ret + } + + return o.Role +} + +// GetRoleOk returns a tuple with the Role field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessListItem) GetRoleOk() (*GatewayAccessRole, bool) { + if o == nil { + return nil, false + } + return &o.Role, true +} + +// SetRole sets field value +func (o *GatewayAccessListItem) SetRole(v GatewayAccessRole) { + o.Role = v +} + +// GetIsCreator returns the IsCreator field value +func (o *GatewayAccessListItem) GetIsCreator() bool { + if o == nil { + var ret bool + return ret + } + + return o.IsCreator +} + +// GetIsCreatorOk returns a tuple with the IsCreator field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessListItem) GetIsCreatorOk() (*bool, bool) { + if o == nil { + return nil, false + } + return &o.IsCreator, true +} + +// SetIsCreator sets field value +func (o *GatewayAccessListItem) SetIsCreator(v bool) { + o.IsCreator = v +} + +// GetGrantedAt returns the GrantedAt field value +func (o *GatewayAccessListItem) GetGrantedAt() time.Time { + if o == nil { + var ret time.Time + return ret + } + + return o.GrantedAt +} + +// GetGrantedAtOk returns a tuple with the GrantedAt field value +// and a boolean to check if the value has been set. +func (o *GatewayAccessListItem) GetGrantedAtOk() (*time.Time, bool) { + if o == nil { + return nil, false + } + return &o.GrantedAt, true +} + +// SetGrantedAt sets field value +func (o *GatewayAccessListItem) SetGrantedAt(v time.Time) { + o.GrantedAt = v +} + +func (o GatewayAccessListItem) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o GatewayAccessListItem) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["role_binding_id"] = o.RoleBindingId + toSerialize["user_id"] = o.UserId + toSerialize["username"] = o.Username + if o.Name.IsSet() { + toSerialize["name"] = o.Name.Get() + } + if o.Email.IsSet() { + toSerialize["email"] = o.Email.Get() + } + toSerialize["role"] = o.Role + toSerialize["is_creator"] = o.IsCreator + toSerialize["granted_at"] = o.GrantedAt + return toSerialize, nil +} + +func (o *GatewayAccessListItem) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "role_binding_id", + "user_id", + "username", + "role", + "is_creator", + "granted_at", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varGatewayAccessListItem := _GatewayAccessListItem{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varGatewayAccessListItem) + + if err != nil { + return err + } + + *o = GatewayAccessListItem(varGatewayAccessListItem) + + return err +} + +type NullableGatewayAccessListItem struct { + value *GatewayAccessListItem + isSet bool +} + +func (v NullableGatewayAccessListItem) Get() *GatewayAccessListItem { + return v.value +} + +func (v *NullableGatewayAccessListItem) Set(val *GatewayAccessListItem) { + v.value = val + v.isSet = true +} + +func (v NullableGatewayAccessListItem) IsSet() bool { + return v.isSet +} + +func (v *NullableGatewayAccessListItem) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableGatewayAccessListItem(val *GatewayAccessListItem) *NullableGatewayAccessListItem { + return &NullableGatewayAccessListItem{value: val, isSet: true} +} + +func (v NullableGatewayAccessListItem) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableGatewayAccessListItem) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/components/api-server/pkg/api/openapi/model_gateway_access_role.go b/components/api-server/pkg/api/openapi/model_gateway_access_role.go new file mode 100644 index 000000000..c48ed8478 --- /dev/null +++ b/components/api-server/pkg/api/openapi/model_gateway_access_role.go @@ -0,0 +1,112 @@ +/* +HyperShell API + +HyperShell gateway management API + +API version: 1.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "encoding/json" + "fmt" +) + +// GatewayAccessRole the model 'GatewayAccessRole' +type GatewayAccessRole string + +// List of GatewayAccessRole +const ( + OWNER GatewayAccessRole = "owner" + ADMIN GatewayAccessRole = "admin" + USER GatewayAccessRole = "user" +) + +// All allowed values of GatewayAccessRole enum +var AllowedGatewayAccessRoleEnumValues = []GatewayAccessRole{ + "owner", + "admin", + "user", +} + +func (v *GatewayAccessRole) UnmarshalJSON(src []byte) error { + var value string + err := json.Unmarshal(src, &value) + if err != nil { + return err + } + enumTypeValue := GatewayAccessRole(value) + for _, existing := range AllowedGatewayAccessRoleEnumValues { + if existing == enumTypeValue { + *v = enumTypeValue + return nil + } + } + + return fmt.Errorf("%+v is not a valid GatewayAccessRole", value) +} + +// NewGatewayAccessRoleFromValue returns a pointer to a valid GatewayAccessRole +// for the value passed as argument, or an error if the value passed is not allowed by the enum +func NewGatewayAccessRoleFromValue(v string) (*GatewayAccessRole, error) { + ev := GatewayAccessRole(v) + if ev.IsValid() { + return &ev, nil + } else { + return nil, fmt.Errorf("invalid value '%v' for GatewayAccessRole: valid values are %v", v, AllowedGatewayAccessRoleEnumValues) + } +} + +// IsValid return true if the value is valid for the enum, false otherwise +func (v GatewayAccessRole) IsValid() bool { + for _, existing := range AllowedGatewayAccessRoleEnumValues { + if existing == v { + return true + } + } + return false +} + +// Ptr returns reference to GatewayAccessRole value +func (v GatewayAccessRole) Ptr() *GatewayAccessRole { + return &v +} + +type NullableGatewayAccessRole struct { + value *GatewayAccessRole + isSet bool +} + +func (v NullableGatewayAccessRole) Get() *GatewayAccessRole { + return v.value +} + +func (v *NullableGatewayAccessRole) Set(val *GatewayAccessRole) { + v.value = val + v.isSet = true +} + +func (v NullableGatewayAccessRole) IsSet() bool { + return v.isSet +} + +func (v *NullableGatewayAccessRole) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableGatewayAccessRole(val *GatewayAccessRole) *NullableGatewayAccessRole { + return &NullableGatewayAccessRole{value: val, isSet: true} +} + +func (v NullableGatewayAccessRole) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableGatewayAccessRole) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/components/api-server/pkg/api/openapi/model_gateway_directory_user.go b/components/api-server/pkg/api/openapi/model_gateway_directory_user.go new file mode 100644 index 000000000..14f80d6cf --- /dev/null +++ b/components/api-server/pkg/api/openapi/model_gateway_directory_user.go @@ -0,0 +1,297 @@ +/* +HyperShell API + +HyperShell gateway management API + +API version: 1.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the GatewayDirectoryUser type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &GatewayDirectoryUser{} + +// GatewayDirectoryUser struct for GatewayDirectoryUser +type GatewayDirectoryUser struct { + Username string `json:"username"` + Name NullableString `json:"name,omitempty"` + Email NullableString `json:"email,omitempty"` + Subject NullableString `json:"subject,omitempty"` +} + +type _GatewayDirectoryUser GatewayDirectoryUser + +// NewGatewayDirectoryUser instantiates a new GatewayDirectoryUser object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewGatewayDirectoryUser(username string) *GatewayDirectoryUser { + this := GatewayDirectoryUser{} + this.Username = username + return &this +} + +// NewGatewayDirectoryUserWithDefaults instantiates a new GatewayDirectoryUser object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewGatewayDirectoryUserWithDefaults() *GatewayDirectoryUser { + this := GatewayDirectoryUser{} + return &this +} + +// GetUsername returns the Username field value +func (o *GatewayDirectoryUser) GetUsername() string { + if o == nil { + var ret string + return ret + } + + return o.Username +} + +// GetUsernameOk returns a tuple with the Username field value +// and a boolean to check if the value has been set. +func (o *GatewayDirectoryUser) GetUsernameOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Username, true +} + +// SetUsername sets field value +func (o *GatewayDirectoryUser) SetUsername(v string) { + o.Username = v +} + +// GetName returns the Name field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *GatewayDirectoryUser) GetName() string { + if o == nil || IsNil(o.Name.Get()) { + var ret string + return ret + } + return *o.Name.Get() +} + +// GetNameOk returns a tuple with the Name field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *GatewayDirectoryUser) GetNameOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Name.Get(), o.Name.IsSet() +} + +// HasName returns a boolean if a field has been set. +func (o *GatewayDirectoryUser) HasName() bool { + if o != nil && o.Name.IsSet() { + return true + } + + return false +} + +// SetName gets a reference to the given NullableString and assigns it to the Name field. +func (o *GatewayDirectoryUser) SetName(v string) { + o.Name.Set(&v) +} + +// SetNameNil sets the value for Name to be an explicit nil +func (o *GatewayDirectoryUser) SetNameNil() { + o.Name.Set(nil) +} + +// UnsetName ensures that no value is present for Name, not even an explicit nil +func (o *GatewayDirectoryUser) UnsetName() { + o.Name.Unset() +} + +// GetEmail returns the Email field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *GatewayDirectoryUser) GetEmail() string { + if o == nil || IsNil(o.Email.Get()) { + var ret string + return ret + } + return *o.Email.Get() +} + +// GetEmailOk returns a tuple with the Email field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *GatewayDirectoryUser) GetEmailOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Email.Get(), o.Email.IsSet() +} + +// HasEmail returns a boolean if a field has been set. +func (o *GatewayDirectoryUser) HasEmail() bool { + if o != nil && o.Email.IsSet() { + return true + } + + return false +} + +// SetEmail gets a reference to the given NullableString and assigns it to the Email field. +func (o *GatewayDirectoryUser) SetEmail(v string) { + o.Email.Set(&v) +} + +// SetEmailNil sets the value for Email to be an explicit nil +func (o *GatewayDirectoryUser) SetEmailNil() { + o.Email.Set(nil) +} + +// UnsetEmail ensures that no value is present for Email, not even an explicit nil +func (o *GatewayDirectoryUser) UnsetEmail() { + o.Email.Unset() +} + +// GetSubject returns the Subject field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *GatewayDirectoryUser) GetSubject() string { + if o == nil || IsNil(o.Subject.Get()) { + var ret string + return ret + } + return *o.Subject.Get() +} + +// GetSubjectOk returns a tuple with the Subject field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *GatewayDirectoryUser) GetSubjectOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Subject.Get(), o.Subject.IsSet() +} + +// HasSubject returns a boolean if a field has been set. +func (o *GatewayDirectoryUser) HasSubject() bool { + if o != nil && o.Subject.IsSet() { + return true + } + + return false +} + +// SetSubject gets a reference to the given NullableString and assigns it to the Subject field. +func (o *GatewayDirectoryUser) SetSubject(v string) { + o.Subject.Set(&v) +} + +// SetSubjectNil sets the value for Subject to be an explicit nil +func (o *GatewayDirectoryUser) SetSubjectNil() { + o.Subject.Set(nil) +} + +// UnsetSubject ensures that no value is present for Subject, not even an explicit nil +func (o *GatewayDirectoryUser) UnsetSubject() { + o.Subject.Unset() +} + +func (o GatewayDirectoryUser) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o GatewayDirectoryUser) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["username"] = o.Username + if o.Name.IsSet() { + toSerialize["name"] = o.Name.Get() + } + if o.Email.IsSet() { + toSerialize["email"] = o.Email.Get() + } + if o.Subject.IsSet() { + toSerialize["subject"] = o.Subject.Get() + } + return toSerialize, nil +} + +func (o *GatewayDirectoryUser) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "username", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varGatewayDirectoryUser := _GatewayDirectoryUser{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varGatewayDirectoryUser) + + if err != nil { + return err + } + + *o = GatewayDirectoryUser(varGatewayDirectoryUser) + + return err +} + +type NullableGatewayDirectoryUser struct { + value *GatewayDirectoryUser + isSet bool +} + +func (v NullableGatewayDirectoryUser) Get() *GatewayDirectoryUser { + return v.value +} + +func (v *NullableGatewayDirectoryUser) Set(val *GatewayDirectoryUser) { + v.value = val + v.isSet = true +} + +func (v NullableGatewayDirectoryUser) IsSet() bool { + return v.isSet +} + +func (v *NullableGatewayDirectoryUser) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableGatewayDirectoryUser(val *GatewayDirectoryUser) *NullableGatewayDirectoryUser { + return &NullableGatewayDirectoryUser{value: val, isSet: true} +} + +func (v NullableGatewayDirectoryUser) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableGatewayDirectoryUser) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/components/api-server/pkg/api/openapi/model_gateway_directory_user_list.go b/components/api-server/pkg/api/openapi/model_gateway_directory_user_list.go new file mode 100644 index 000000000..28dec9bf5 --- /dev/null +++ b/components/api-server/pkg/api/openapi/model_gateway_directory_user_list.go @@ -0,0 +1,156 @@ +/* +HyperShell API + +HyperShell gateway management API + +API version: 1.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the GatewayDirectoryUserList type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &GatewayDirectoryUserList{} + +// GatewayDirectoryUserList struct for GatewayDirectoryUserList +type GatewayDirectoryUserList struct { + Items []GatewayDirectoryUser `json:"items"` +} + +type _GatewayDirectoryUserList GatewayDirectoryUserList + +// NewGatewayDirectoryUserList instantiates a new GatewayDirectoryUserList object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewGatewayDirectoryUserList(items []GatewayDirectoryUser) *GatewayDirectoryUserList { + this := GatewayDirectoryUserList{} + this.Items = items + return &this +} + +// NewGatewayDirectoryUserListWithDefaults instantiates a new GatewayDirectoryUserList object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewGatewayDirectoryUserListWithDefaults() *GatewayDirectoryUserList { + this := GatewayDirectoryUserList{} + return &this +} + +// GetItems returns the Items field value +func (o *GatewayDirectoryUserList) GetItems() []GatewayDirectoryUser { + if o == nil { + var ret []GatewayDirectoryUser + return ret + } + + return o.Items +} + +// GetItemsOk returns a tuple with the Items field value +// and a boolean to check if the value has been set. +func (o *GatewayDirectoryUserList) GetItemsOk() ([]GatewayDirectoryUser, bool) { + if o == nil { + return nil, false + } + return o.Items, true +} + +// SetItems sets field value +func (o *GatewayDirectoryUserList) SetItems(v []GatewayDirectoryUser) { + o.Items = v +} + +func (o GatewayDirectoryUserList) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o GatewayDirectoryUserList) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["items"] = o.Items + return toSerialize, nil +} + +func (o *GatewayDirectoryUserList) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "items", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varGatewayDirectoryUserList := _GatewayDirectoryUserList{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varGatewayDirectoryUserList) + + if err != nil { + return err + } + + *o = GatewayDirectoryUserList(varGatewayDirectoryUserList) + + return err +} + +type NullableGatewayDirectoryUserList struct { + value *GatewayDirectoryUserList + isSet bool +} + +func (v NullableGatewayDirectoryUserList) Get() *GatewayDirectoryUserList { + return v.value +} + +func (v *NullableGatewayDirectoryUserList) Set(val *GatewayDirectoryUserList) { + v.value = val + v.isSet = true +} + +func (v NullableGatewayDirectoryUserList) IsSet() bool { + return v.isSet +} + +func (v *NullableGatewayDirectoryUserList) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableGatewayDirectoryUserList(val *GatewayDirectoryUserList) *NullableGatewayDirectoryUserList { + return &NullableGatewayDirectoryUserList{value: val, isSet: true} +} + +func (v NullableGatewayDirectoryUserList) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableGatewayDirectoryUserList) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/components/api-server/pkg/rbac/authorization.go b/components/api-server/pkg/rbac/authorization.go index dd18b2336..d0fdcfaa5 100644 --- a/components/api-server/pkg/rbac/authorization.go +++ b/components/api-server/pkg/rbac/authorization.go @@ -140,7 +140,7 @@ func (m *rbacAuthzMiddleware) AuthorizeApi(next http.Handler) http.Handler { } if !isAuthorized(r.Method, resource, resourceID, gatewayID, bindings, jwtRoles) { - if resource == "service_accounts" || (r.Method == http.MethodGet && resourceID != "") { + if resource == "service_accounts" || resource == "access" || (r.Method == http.MethodGet && resourceID != "") { http.Error(w, "Not Found", http.StatusNotFound) } else { http.Error(w, "Forbidden", http.StatusForbidden) @@ -280,6 +280,12 @@ func extractResourceInfoFromRoute(r *http.Request) (resource string, resourceID if strings.Contains(pathTemplate, "/gateways/{gateway_id}/service_accounts") { return "service_accounts", mux.Vars(r)["service_account_id"] } + if strings.Contains(pathTemplate, "/gateways/{gateway_id}/access") { + // Collection, item (/access/{user_id}), and directory (/access/directory) + // all resolve to the "access" resource; user_id is empty for the + // collection and directory sub-paths. + return "access", mux.Vars(r)["user_id"] + } parts := strings.Split(pathTemplate, "/") for i := len(parts) - 1; i >= 0; i-- { @@ -318,6 +324,16 @@ func extractResourceInfoFromPath(path string) (resource string, resourceID strin } } } + if strings.Contains(remainder, "gateways/") && strings.Contains(remainder, "/access") { + for i, part := range parts { + if part == "access" { + if i+1 < len(parts) && parts[i+1] != "directory" { + return "access", parts[i+1] + } + return "access", "" + } + } + } resource = parts[0] if len(parts) > 1 { @@ -327,7 +343,7 @@ func extractResourceInfoFromPath(path string) (resource string, resourceID strin } func extractGatewayID(r *http.Request, resource string) string { - if resource == "service_accounts" { + if resource == "service_accounts" || resource == "access" { if gatewayID := mux.Vars(r)["gateway_id"]; gatewayID != "" { return gatewayID } @@ -403,17 +419,22 @@ func isAuthorized(method string, resource string, resourceID string, gatewayID s } if resource == "service_accounts" && gatewayID != "" { - // Both gateway roles can create service accounts. The handler applies the - // finer owner-all/viewer-own visibility and role-cap rules. + // Any gateway role can reach the service-accounts API. The handler applies + // the finer owner/admin-all vs viewer-own visibility and role-cap rules + // (admins are capped like owners -- GAM-11). for _, binding := range bindings { if binding.Scope == "gateway" && binding.GatewayID != nil && *binding.GatewayID == gatewayID && - (binding.RoleName == "gateway:owner" || binding.RoleName == "gateway:viewer") { + (binding.RoleName == "gateway:owner" || binding.RoleName == "gateway:admin" || binding.RoleName == "gateway:viewer") { return true } } return false } + if resource == "access" && gatewayID != "" { + return isAccessFacadeAuthorized(gatewayID, bindings) + } + if resource == "role_bindings" { return len(bindings) > 0 } @@ -442,6 +463,12 @@ func isGatewayAuthorized(method string, gatewayID string, bindings []BindingSumm switch b.RoleName { case "gateway:owner": return true + case "gateway:admin": + // Granted administrator: read and update, but never delete the + // gateway (GAM-01). Delete requires gateway:owner. + if method != http.MethodDelete { + return true + } case "gateway:viewer": if method == http.MethodGet { return true @@ -450,3 +477,22 @@ func isGatewayAuthorized(method string, gatewayID string, bindings []BindingSumm } return false } + +// isAccessFacadeAuthorized is the coarse gate for the gateway access facade +// (/gateways/{gateway_id}/access...). Any caller with a binding on the gateway +// (owner, admin, or viewer) or platform:admin passes; everyone else gets 404 +// (existence is not disclosed, per rbac-enforcement.spec.md). The fine-grained +// rules -- owner/admin-only management, owner-tier gating, and last-owner +// protection -- are enforced in the access facade service (403/409), which can +// return descriptive messages the middleware cannot. +func isAccessFacadeAuthorized(gatewayID string, bindings []BindingSummary) bool { + if hasPlatformAdmin(bindings) { + return true + } + for _, b := range bindings { + if b.Scope == "gateway" && b.GatewayID != nil && *b.GatewayID == gatewayID { + return true + } + } + return false +} diff --git a/components/api-server/plugins/gatewayAccess/dao.go b/components/api-server/plugins/gatewayAccess/dao.go new file mode 100644 index 000000000..3b2a9d24f --- /dev/null +++ b/components/api-server/plugins/gatewayAccess/dao.go @@ -0,0 +1,148 @@ +package gatewayAccess + +import ( + "context" + "strings" + "time" + + "gorm.io/gorm" + + "github.com/openshift-online/hypershell/components/api-server/plugins/roleBindings" + "github.com/openshift-online/hypershell/components/api-server/plugins/roles" + "github.com/openshift-online/rh-trex-ai/components/api-server/pkg/db" +) + +// accessRow is one enriched access grant, assembled server-side by joining +// role_bindings to users and roles (GAM-03: no per-row user lookup). +type accessRow struct { + RoleBindingID string `gorm:"column:role_binding_id"` + UserID string `gorm:"column:user_id"` + Username string `gorm:"column:username"` + Name *string `gorm:"column:name"` + Email *string `gorm:"column:email"` + RoleName string `gorm:"column:role_name"` + GrantedAt time.Time `gorm:"column:granted_at"` +} + +type dao struct { + sessionFactory *db.SessionFactory +} + +func newDao(sf *db.SessionFactory) *dao { return &dao{sessionFactory: sf} } + +var sortColumns = map[string]string{ + "granted_at": "rb.created_at", + "username": "u.username", + "name": "u.name", + "role": "r.name", +} + +// filtered builds a fresh query for the gateway's access grants with the role +// filter and literal search applied. A new session is returned on every call so +// the count and page queries do not share mutated statement state. +func (d *dao) filtered(ctx context.Context, gatewayID string, opts ListOptions) *gorm.DB { + q := (*d.sessionFactory).New(ctx). + Table("role_bindings AS rb"). + Joins("JOIN roles r ON r.id = rb.role_id AND r.deleted_at IS NULL"). + Joins("JOIN users u ON u.id = rb.user_id AND u.deleted_at IS NULL"). + Where("rb.gateway_id = ? AND rb.scope = ? AND rb.deleted_at IS NULL", gatewayID, roleBindings.ScopeGateway) + + if roleName := tierToRoleName(opts.Role); roleName != "" { + q = q.Where("r.name = ?", roleName) + } + if s := strings.TrimSpace(opts.Search); s != "" { + // Treat the search term as a literal: escape LIKE metacharacters so a + // user typing "%" or "_" matches those characters, not wildcards. + pattern := "%" + escapeLike(strings.ToLower(s)) + "%" + q = q.Where(`(LOWER(u.username) LIKE ? ESCAPE '\' OR LOWER(COALESCE(u.name, '')) LIKE ? ESCAPE '\')`, pattern, pattern) + } + return q +} + +func (d *dao) list(ctx context.Context, gatewayID string, opts ListOptions) ([]accessRow, int64, error) { + var total int64 + if err := d.filtered(ctx, gatewayID, opts).Count(&total).Error; err != nil { + return nil, 0, err + } + + sortCol := sortColumns[opts.Sort] + if sortCol == "" { + sortCol = "rb.created_at" + } + order := "DESC" + if strings.EqualFold(opts.Order, "asc") { + order = "ASC" + } + + rows := []accessRow{} + q := d.filtered(ctx, gatewayID, opts). + Select("rb.id AS role_binding_id, rb.user_id AS user_id, u.username AS username, u.name AS name, u.email AS email, r.name AS role_name, rb.created_at AS granted_at"). + Order(sortCol + " " + order). + Order("rb.id ASC") + if opts.Size > 0 { + q = q.Limit(opts.Size).Offset((opts.Page - 1) * opts.Size) + } + if err := q.Find(&rows).Error; err != nil { + return nil, 0, err + } + return rows, total, nil +} + +// getForUser returns the enriched grant row for a single user on the gateway +// (used to shape the response after a grant/change write). +func (d *dao) getForUser(ctx context.Context, gatewayID, userID string) (accessRow, bool, error) { + rows := []accessRow{} + err := (*d.sessionFactory).New(ctx). + Table("role_bindings AS rb"). + Joins("JOIN roles r ON r.id = rb.role_id AND r.deleted_at IS NULL"). + Joins("JOIN users u ON u.id = rb.user_id AND u.deleted_at IS NULL"). + Where("rb.gateway_id = ? AND rb.scope = ? AND rb.user_id = ? AND rb.deleted_at IS NULL", gatewayID, roleBindings.ScopeGateway, userID). + Select("rb.id AS role_binding_id, rb.user_id AS user_id, u.username AS username, u.name AS name, u.email AS email, r.name AS role_name, rb.created_at AS granted_at"). + Order("rb.created_at ASC"). + Order("rb.id ASC"). + Limit(1). + Find(&rows).Error + if err != nil { + return accessRow{}, false, err + } + if len(rows) == 0 { + return accessRow{}, false, nil + } + return rows[0], true, nil +} + +// creatorUserID returns the user_id of the earliest-created gateway:owner +// binding, which is the creator (the auto-provisioned first owner). Empty when +// the gateway has no owner. +func (d *dao) creatorUserID(ctx context.Context, gatewayID string) (string, error) { + var ids []string + err := (*d.sessionFactory).New(ctx). + Table("role_bindings AS rb"). + Joins("JOIN roles r ON r.id = rb.role_id AND r.deleted_at IS NULL"). + Where("rb.gateway_id = ? AND rb.scope = ? AND rb.deleted_at IS NULL AND r.name = ?", gatewayID, roleBindings.ScopeGateway, roles.RoleGatewayOwner). + Order("rb.created_at ASC"). + Order("rb.id ASC"). + Limit(1). + Pluck("rb.user_id", &ids).Error + if err != nil || len(ids) == 0 { + return "", err + } + return ids[0], nil +} + +// ownerUserIDs returns the distinct user_ids holding gateway:owner on the +// gateway. Used for last-owner protection (GAM-07). +func (d *dao) ownerUserIDs(ctx context.Context, gatewayID string) ([]string, error) { + var ids []string + err := (*d.sessionFactory).New(ctx). + Table("role_bindings AS rb"). + Joins("JOIN roles r ON r.id = rb.role_id AND r.deleted_at IS NULL"). + Where("rb.gateway_id = ? AND rb.scope = ? AND rb.deleted_at IS NULL AND r.name = ?", gatewayID, roleBindings.ScopeGateway, roles.RoleGatewayOwner). + Distinct("rb.user_id"). + Pluck("rb.user_id", &ids).Error + return ids, err +} + +func escapeLike(s string) string { + return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s) +} diff --git a/components/api-server/plugins/gatewayAccess/directory.go b/components/api-server/plugins/gatewayAccess/directory.go new file mode 100644 index 000000000..7901ae8f8 --- /dev/null +++ b/components/api-server/plugins/gatewayAccess/directory.go @@ -0,0 +1,76 @@ +package gatewayAccess + +import ( + "context" + "errors" + "log" + "os" +) + +// DirectoryUser is a candidate from the Keycloak realm directory projection +// (GAM-09). Subject is the Keycloak user's `sub` (its user UUID). +type DirectoryUser struct { + Username string + Name string + Email string + Subject string +} + +// DirectoryResolver serves the Keycloak realm directory projection maintained by +// the control plane (GAM-09). The API server never reads the Keycloak admin +// Secret; it reads this projection. Search backs the console "Add users" picker; +// Resolve re-validates a chosen identity against the realm at grant time (GAM-04). +type DirectoryResolver interface { + // Search returns realm candidates whose username or name match query + // (case-insensitive substring), bounded by limit. + Search(ctx context.Context, query string, limit int) ([]DirectoryUser, error) + // Resolve returns the realm user for an exact username. found is false when + // no such user exists in the realm. + Resolve(ctx context.Context, username string) (user DirectoryUser, found bool, err error) +} + +// ErrDirectoryUnavailable is returned by the unconfigured resolver so callers +// can surface a clear "directory not available" error rather than a nil panic. +var ErrDirectoryUnavailable = errors.New("keycloak directory projection is not configured") + +// directoryResolverOverride, when set, is the resolver the access facade uses. +// The control-plane-backed resolver is installed at startup (Wave C wiring) and +// tests install a fake; when unset the facade uses the unconfigured resolver. +var directoryResolverOverride DirectoryResolver + +// SetDirectoryResolver installs the directory resolver used by the access facade. +func SetDirectoryResolver(r DirectoryResolver) { directoryResolverOverride = r } + +// newDirectoryResolverFromEnvironment builds the directory resolver for the +// running environment. Until a control-plane-backed resolver is installed, +// grant-by-directory and directory search fail loudly with +// ErrDirectoryUnavailable rather than silently resolving nothing. +func newDirectoryResolverFromEnvironment() DirectoryResolver { + if directoryResolverOverride != nil { + return directoryResolverOverride + } + // The directory projection rides the same in-cluster control-plane channel as + // the service-account provisioner, so it reuses that address. + address := os.Getenv("HYPERSHELL_SERVICE_ACCOUNT_PROVISIONER_ADDR") + if address == "" { + return unconfiguredResolver{} + } + resolver, err := newGRPCDirectoryResolver(address) + if err != nil { + log.Printf("WARN gateway access directory resolver is not available: %v", err) + return unconfiguredResolver{} + } + return resolver +} + +// unconfiguredResolver is used when no control-plane directory endpoint is +// configured. It fails loudly rather than silently resolving nothing. +type unconfiguredResolver struct{} + +func (unconfiguredResolver) Search(context.Context, string, int) ([]DirectoryUser, error) { + return nil, ErrDirectoryUnavailable +} + +func (unconfiguredResolver) Resolve(context.Context, string) (DirectoryUser, bool, error) { + return DirectoryUser{}, false, ErrDirectoryUnavailable +} diff --git a/components/api-server/plugins/gatewayAccess/directory_client.go b/components/api-server/plugins/gatewayAccess/directory_client.go new file mode 100644 index 000000000..85b27e7b4 --- /dev/null +++ b/components/api-server/plugins/gatewayAccess/directory_client.go @@ -0,0 +1,63 @@ +package gatewayAccess + +import ( + "context" + "fmt" + "time" + + provisionerpb "github.com/openshift-online/hypershell/components/api-server/pkg/api/grpc/hypershell/provisioner/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" +) + +const directoryCallTimeout = 30 * time.Second + +// grpcDirectoryResolver reads the control-plane Keycloak directory projection +// over the private in-cluster gRPC channel (GAM-09). It mirrors the +// service-account provisioner client's plaintext in-cluster dial. +type grpcDirectoryResolver struct { + client provisionerpb.DirectoryServiceClient +} + +func newGRPCDirectoryResolver(address string) (DirectoryResolver, error) { + // grpc-go DNS does not apply kube-DNS search domains; callers pass a + // cluster-local FQDN (see deploy/base/api-server.yaml). + conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, fmt.Errorf("create directory client: %w", err) + } + return &grpcDirectoryResolver{client: provisionerpb.NewDirectoryServiceClient(conn)}, nil +} + +func (r *grpcDirectoryResolver) Search(ctx context.Context, query string, limit int) ([]DirectoryUser, error) { + callCtx, cancel := context.WithTimeout(ctx, directoryCallTimeout) + defer cancel() + resp, err := r.client.SearchDirectory(callCtx, &provisionerpb.SearchDirectoryRequest{Query: query, Limit: int32(limit)}) + if err != nil { + return nil, err + } + out := make([]DirectoryUser, 0, len(resp.GetUsers())) + for _, u := range resp.GetUsers() { + out = append(out, DirectoryUser{Username: u.GetUsername(), Name: u.GetName(), Email: u.GetEmail(), Subject: u.GetSubject()}) + } + return out, nil +} + +func (r *grpcDirectoryResolver) Resolve(ctx context.Context, username string) (DirectoryUser, bool, error) { + callCtx, cancel := context.WithTimeout(ctx, directoryCallTimeout) + defer cancel() + resp, err := r.client.ResolveUser(callCtx, &provisionerpb.ResolveUserRequest{Username: username}) + if err != nil { + if status.Code(err) == codes.NotFound { + return DirectoryUser{}, false, nil + } + return DirectoryUser{}, false, err + } + if !resp.GetFound() || resp.GetUser() == nil { + return DirectoryUser{}, false, nil + } + u := resp.GetUser() + return DirectoryUser{Username: u.GetUsername(), Name: u.GetName(), Email: u.GetEmail(), Subject: u.GetSubject()}, true, nil +} diff --git a/components/api-server/plugins/gatewayAccess/errors.go b/components/api-server/plugins/gatewayAccess/errors.go new file mode 100644 index 000000000..640c59fa7 --- /dev/null +++ b/components/api-server/plugins/gatewayAccess/errors.go @@ -0,0 +1,55 @@ +package gatewayAccess + +import ( + "fmt" + "net/http" + + trexerrors "github.com/openshift-online/rh-trex-ai/components/api-server/pkg/errors" +) + +// APIError is the hand-rolled problem returned by the access facade handlers, +// serialized as {"code","reason"} to match the shared SDK/CLI error contract. +type APIError struct { + Status int + Code string + Message string +} + +func (e *APIError) Error() string { return e.Message } + +func validationProblem(msg string) *APIError { + return &APIError{Status: http.StatusBadRequest, Code: "invalid-request", Message: msg} +} + +func forbidden(msg string) *APIError { + return &APIError{Status: http.StatusForbidden, Code: "forbidden", Message: msg} +} + +func notFound(msg string) *APIError { + return &APIError{Status: http.StatusNotFound, Code: "not-found", Message: msg} +} + +func conflictErr(msg string) *APIError { + return &APIError{Status: http.StatusConflict, Code: "conflict", Message: msg} +} + +func serverError(msg string) *APIError { + return &APIError{Status: http.StatusInternalServerError, Code: "server-error", Message: msg} +} + +func serviceUnavailable(msg string) *APIError { + return &APIError{Status: http.StatusServiceUnavailable, Code: "service-unavailable", Message: msg} +} + +// fromServiceError maps a trex ServiceError (returned by the shared roleBindings +// service) to the facade's APIError, preserving the HTTP status. +func fromServiceError(serr *trexerrors.ServiceError) *APIError { + if serr == nil { + return nil + } + status := serr.HttpCode + if status == 0 { + status = http.StatusInternalServerError + } + return &APIError{Status: status, Code: fmt.Sprintf("role-binding-%v", serr.Code), Message: serr.Reason} +} diff --git a/components/api-server/plugins/gatewayAccess/handler.go b/components/api-server/plugins/gatewayAccess/handler.go new file mode 100644 index 000000000..39fa2f2f8 --- /dev/null +++ b/components/api-server/plugins/gatewayAccess/handler.go @@ -0,0 +1,161 @@ +package gatewayAccess + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + + "github.com/gorilla/mux" + + "github.com/openshift-online/hypershell/components/api-server/pkg/rbac" +) + +type handler struct{ service Service } + +func NewHandler(service Service) *handler { return &handler{service: service} } + +type grantRequest struct { + Username string `json:"username"` + Subject string `json:"subject"` + Role string `json:"role"` +} + +type changeRoleRequest struct { + Role string `json:"role"` +} + +func (h *handler) List(w http.ResponseWriter, r *http.Request) { + options, problem := parseListOptions(r) + if problem != nil { + writeProblem(w, problem) + return + } + vars := mux.Vars(r) + items, total, caps, problem := h.service.List(r.Context(), vars["gateway_id"], rbac.GetUserIDFromContext(r.Context()), options) + if problem != nil { + writeProblem(w, problem) + return + } + response := listResponse{Page: options.Page, Size: options.Size, Total: total, Capabilities: presentCapabilities(caps), Items: make([]grantItemResponse, 0, len(items))} + for _, item := range items { + response.Items = append(response.Items, presentGrant(item)) + } + writeJSON(w, http.StatusOK, response) +} + +func (h *handler) Grant(w http.ResponseWriter, r *http.Request) { + var request grantRequest + if err := decodeStrictJSON(r, &request); err != nil { + writeProblem(w, validationProblem("The request body is not valid JSON or contains an unknown field")) + return + } + vars := mux.Vars(r) + item, problem := h.service.Grant(r.Context(), vars["gateway_id"], rbac.GetUserIDFromContext(r.Context()), GrantInput(request)) + if problem != nil { + writeProblem(w, problem) + return + } + writeJSON(w, http.StatusCreated, presentGrant(item)) +} + +func (h *handler) ChangeRole(w http.ResponseWriter, r *http.Request) { + var request changeRoleRequest + if err := decodeStrictJSON(r, &request); err != nil { + writeProblem(w, validationProblem("The request body is not valid JSON or contains an unknown field")) + return + } + vars := mux.Vars(r) + item, problem := h.service.ChangeRole(r.Context(), vars["gateway_id"], rbac.GetUserIDFromContext(r.Context()), vars["user_id"], request.Role) + if problem != nil { + writeProblem(w, problem) + return + } + writeJSON(w, http.StatusOK, presentGrant(item)) +} + +func (h *handler) Revoke(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + problem := h.service.Revoke(r.Context(), vars["gateway_id"], rbac.GetUserIDFromContext(r.Context()), vars["user_id"]) + if problem != nil { + writeProblem(w, problem) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *handler) SearchDirectory(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + candidates, problem := h.service.SearchDirectory(r.Context(), vars["gateway_id"], rbac.GetUserIDFromContext(r.Context()), r.URL.Query().Get("search")) + if problem != nil { + writeProblem(w, problem) + return + } + response := directoryResponse{Items: make([]directoryCandidateResponse, 0, len(candidates))} + for _, c := range candidates { + response.Items = append(response.Items, presentCandidate(c)) + } + writeJSON(w, http.StatusOK, response) +} + +func parseListOptions(r *http.Request) (ListOptions, *APIError) { + query := r.URL.Query() + options := ListOptions{Page: 1, Size: 20, Sort: "granted_at", Order: "desc", Search: query.Get("search"), Role: query.Get("role")} + var err error + if value := query.Get("page"); value != "" { + options.Page, err = strconv.Atoi(value) + if err != nil || options.Page < 1 { + return ListOptions{}, validationProblem("page must be an integer greater than zero") + } + } + if value := query.Get("size"); value != "" { + options.Size, err = strconv.Atoi(value) + if err != nil || options.Size < 1 || options.Size > 100 { + return ListOptions{}, validationProblem("size must be between 1 and 100") + } + } + if value := query.Get("sort"); value != "" { + options.Sort = value + } + if value := query.Get("order"); value != "" { + options.Order = value + } + validRole := map[string]bool{"": true, TierOwner: true, TierAdmin: true, TierUser: true} + validSort := map[string]bool{"granted_at": true, "username": true, "name": true, "role": true} + if !validRole[options.Role] { + return ListOptions{}, validationProblem("role filter must be one of owner, admin, user") + } + if !validSort[options.Sort] { + return ListOptions{}, validationProblem("sort is not valid") + } + if options.Order != "asc" && options.Order != "desc" { + return ListOptions{}, validationProblem("order must be asc or desc") + } + return options, nil +} + +func decodeStrictJSON(r *http.Request, target any) error { + decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + return errors.New("multiple JSON values") + } + return nil +} + +func writeProblem(w http.ResponseWriter, problem *APIError) { + writeJSON(w, problem.Status, map[string]string{"code": problem.Code, "reason": problem.Message}) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if status != http.StatusNoContent { + _ = json.NewEncoder(w).Encode(value) + } +} diff --git a/components/api-server/plugins/gatewayAccess/integration_test.go b/components/api-server/plugins/gatewayAccess/integration_test.go new file mode 100644 index 000000000..73e6dbdc9 --- /dev/null +++ b/components/api-server/plugins/gatewayAccess/integration_test.go @@ -0,0 +1,370 @@ +package gatewayAccess_test + +import ( + "context" + "net/http" + "strings" + "testing" + + . "github.com/onsi/gomega" + + "github.com/openshift-online/hypershell/components/api-server/plugins/gatewayAccess" + "github.com/openshift-online/hypershell/components/api-server/plugins/roleBindings" + "github.com/openshift-online/hypershell/components/api-server/plugins/roles" + "github.com/openshift-online/hypershell/components/api-server/plugins/users" + "github.com/openshift-online/hypershell/components/api-server/test" + "github.com/openshift-online/rh-trex-ai/components/api-server/pkg/environments" +) + +// fakeDirectory is an in-memory Keycloak realm directory keyed by lowercased +// username, standing in for the control-plane projection (GAM-09). +type fakeDirectory struct { + users map[string]gatewayAccess.DirectoryUser +} + +func (f fakeDirectory) Resolve(_ context.Context, username string) (gatewayAccess.DirectoryUser, bool, error) { + u, ok := f.users[strings.ToLower(username)] + return u, ok, nil +} + +func (f fakeDirectory) Search(_ context.Context, query string, _ int) ([]gatewayAccess.DirectoryUser, error) { + q := strings.ToLower(query) + out := []gatewayAccess.DirectoryUser{} + for _, u := range f.users { + if q == "" || strings.Contains(strings.ToLower(u.Username), q) || strings.Contains(strings.ToLower(u.Name), q) { + out = append(out, u) + } + } + return out, nil +} + +func realm(users ...gatewayAccess.DirectoryUser) fakeDirectory { + m := map[string]gatewayAccess.DirectoryUser{} + for _, u := range users { + m[strings.ToLower(u.Username)] = u + } + return fakeDirectory{users: m} +} + +func rbService() roleBindings.RoleBindingService { + return roleBindings.Service(&environments.Environment().Services) +} +func userService() users.UserService { + return users.Service(&environments.Environment().Services) +} + +// seed provisions a user and gives them roleName on gatewayID, returning the +// user's id. It uses the service's convergence path (no caller authz). +func seed(t *testing.T, gatewayID, username, roleName string) string { + t.Helper() + uid, err := userService().UpsertByUsername(context.Background(), strings.ToLower(username), nil, nil) + Expect(err).NotTo(HaveOccurred()) + _, serr := rbService().SetGatewayRole(context.Background(), uid, gatewayID, roleName) + Expect(serr).To(BeNil()) + return uid +} + +// accessSvc returns the real facade service with a fake directory installed. +func accessSvc(dir gatewayAccess.DirectoryResolver) gatewayAccess.Service { + gatewayAccess.SetDirectoryResolver(dir) + return gatewayAccess.ServiceFrom(&environments.Environment().Services) +} + +func TestAccessList_EnrichedTiersAndCreator(t *testing.T) { + test.RegisterIntegration(t) + defer gatewayAccess.SetDirectoryResolver(nil) + gw := "gw-list-enriched" + + creator := seed(t, gw, "creator-user", roles.RoleGatewayOwner) + seed(t, gw, "second-owner", roles.RoleGatewayOwner) + seed(t, gw, "an-admin", roles.RoleGatewayAdmin) + seed(t, gw, "viewer-one", roles.RoleGatewayViewer) + seed(t, gw, "viewer-two", roles.RoleGatewayViewer) + + svc := accessSvc(realm()) + items, total, caps, aerr := svc.List(context.Background(), gw, creator, gatewayAccess.ListOptions{Page: 1, Size: 50}) + Expect(aerr).To(BeNil()) + Expect(total).To(Equal(int64(5))) + Expect(items).To(HaveLen(5)) + + creators := 0 + tiers := map[string]int{} + for _, it := range items { + tiers[it.Role]++ + if it.IsCreator { + creators++ + Expect(it.Role).To(Equal(gatewayAccess.TierOwner)) + Expect(it.UserID).To(Equal(creator)) + } + } + Expect(creators).To(Equal(1)) + Expect(tiers[gatewayAccess.TierOwner]).To(Equal(2)) + Expect(tiers[gatewayAccess.TierAdmin]).To(Equal(1)) + Expect(tiers[gatewayAccess.TierUser]).To(Equal(2)) + + // Caller is an owner. + Expect(caps.CallerRole).To(Equal(gatewayAccess.TierOwner)) + Expect(caps.CanManageAccess).To(BeTrue()) + Expect(caps.CanManageOwners).To(BeTrue()) +} + +func TestAccessList_FilterByRoleAndSearch(t *testing.T) { + test.RegisterIntegration(t) + defer gatewayAccess.SetDirectoryResolver(nil) + gw := "gw-list-filter" + + owner := seed(t, gw, "owner-ff", roles.RoleGatewayOwner) + seed(t, gw, "alice-user", roles.RoleGatewayViewer) + seed(t, gw, "bob-user", roles.RoleGatewayViewer) + seed(t, gw, "alice-admin", roles.RoleGatewayAdmin) + + svc := accessSvc(realm()) + items, _, _, aerr := svc.List(context.Background(), gw, owner, gatewayAccess.ListOptions{Page: 1, Size: 50, Role: gatewayAccess.TierUser, Search: "ali"}) + Expect(aerr).To(BeNil()) + Expect(items).To(HaveLen(1)) + Expect(items[0].Username).To(Equal("alice-user")) + Expect(items[0].Role).To(Equal(gatewayAccess.TierUser)) +} + +func TestGrant_NeverSignedInUserPreProvisions(t *testing.T) { + test.RegisterIntegration(t) + defer gatewayAccess.SetDirectoryResolver(nil) + gw := "gw-grant-newuser" + owner := seed(t, gw, "owner-np", roles.RoleGatewayOwner) + + dir := realm(gatewayAccess.DirectoryUser{Username: "dana", Name: "Dana Scully", Email: "dana@example.com", Subject: "sub-dana"}) + svc := accessSvc(dir) + + item, aerr := svc.Grant(context.Background(), gw, owner, gatewayAccess.GrantInput{Username: "dana", Role: gatewayAccess.TierUser}) + Expect(aerr).To(BeNil()) + Expect(item.Role).To(Equal(gatewayAccess.TierUser)) + Expect(item.Username).To(Equal("dana")) + + // A HyperShell user record was pre-provisioned from the directory. + u, uerr := userService().GetByUsername(context.Background(), "dana") + Expect(uerr).To(BeNil()) + Expect(u.Name).NotTo(BeNil()) + Expect(*u.Name).To(Equal("Dana Scully")) +} + +func TestGrant_UnknownRealmUserRejected(t *testing.T) { + test.RegisterIntegration(t) + defer gatewayAccess.SetDirectoryResolver(nil) + gw := "gw-grant-ghost" + owner := seed(t, gw, "owner-gh", roles.RoleGatewayOwner) + + svc := accessSvc(realm()) // empty realm + _, aerr := svc.Grant(context.Background(), gw, owner, gatewayAccess.GrantInput{Username: "ghost", Role: gatewayAccess.TierUser}) + Expect(aerr).NotTo(BeNil()) + Expect(aerr.Status).To(Equal(http.StatusNotFound)) + + // No user record pre-provisioned. + _, uerr := userService().GetByUsername(context.Background(), "ghost") + Expect(uerr).NotTo(BeNil()) +} + +func TestGrant_AdminCannotAssignOwner(t *testing.T) { + test.RegisterIntegration(t) + defer gatewayAccess.SetDirectoryResolver(nil) + gw := "gw-admin-noowner" + seed(t, gw, "owner-ao", roles.RoleGatewayOwner) + admin := seed(t, gw, "admin-ao", roles.RoleGatewayAdmin) + + dir := realm(gatewayAccess.DirectoryUser{Username: "erin", Name: "Erin"}) + svc := accessSvc(dir) + _, aerr := svc.Grant(context.Background(), gw, admin, gatewayAccess.GrantInput{Username: "erin", Role: gatewayAccess.TierOwner}) + Expect(aerr).NotTo(BeNil()) + Expect(aerr.Status).To(Equal(http.StatusForbidden)) +} + +func TestGrant_AdminCanGrantUser(t *testing.T) { + test.RegisterIntegration(t) + defer gatewayAccess.SetDirectoryResolver(nil) + gw := "gw-admin-grants" + seed(t, gw, "owner-ag", roles.RoleGatewayOwner) + admin := seed(t, gw, "admin-ag", roles.RoleGatewayAdmin) + + dir := realm(gatewayAccess.DirectoryUser{Username: "dee", Name: "Dee"}) + svc := accessSvc(dir) + item, aerr := svc.Grant(context.Background(), gw, admin, gatewayAccess.GrantInput{Username: "dee", Role: gatewayAccess.TierUser}) + Expect(aerr).To(BeNil()) + Expect(item.Role).To(Equal(gatewayAccess.TierUser)) +} + +func TestChangeRole_PromoteInPlaceKeepsBindingID(t *testing.T) { + test.RegisterIntegration(t) + defer gatewayAccess.SetDirectoryResolver(nil) + gw := "gw-change-inplace" + owner := seed(t, gw, "owner-ip", roles.RoleGatewayOwner) + target := seed(t, gw, "target-ip", roles.RoleGatewayViewer) + + svc := accessSvc(realm()) + // Capture the original binding id. + before, _, _, _ := svc.List(context.Background(), gw, owner, gatewayAccess.ListOptions{Page: 1, Size: 50}) + var beforeID string + for _, it := range before { + if it.UserID == target { + beforeID = it.RoleBindingID + } + } + Expect(beforeID).NotTo(BeEmpty()) + + item, aerr := svc.ChangeRole(context.Background(), gw, owner, target, gatewayAccess.TierAdmin) + Expect(aerr).To(BeNil()) + Expect(item.Role).To(Equal(gatewayAccess.TierAdmin)) + // In-place update (GAM-05): same binding id, no delete-then-create. + Expect(item.RoleBindingID).To(Equal(beforeID)) + + // Exactly one binding remains for the target (GAM-10). + after, _, _, _ := svc.List(context.Background(), gw, owner, gatewayAccess.ListOptions{Page: 1, Size: 50}) + count := 0 + for _, it := range after { + if it.UserID == target { + count++ + } + } + Expect(count).To(Equal(1)) +} + +func TestChangeRole_AdminCannotPromoteToOwner(t *testing.T) { + test.RegisterIntegration(t) + defer gatewayAccess.SetDirectoryResolver(nil) + gw := "gw-admin-nopromote" + seed(t, gw, "owner-ap2", roles.RoleGatewayOwner) + admin := seed(t, gw, "admin-ap2", roles.RoleGatewayAdmin) + target := seed(t, gw, "viewer-ap2", roles.RoleGatewayViewer) + + svc := accessSvc(realm()) + _, aerr := svc.ChangeRole(context.Background(), gw, admin, target, gatewayAccess.TierOwner) + Expect(aerr).NotTo(BeNil()) + Expect(aerr.Status).To(Equal(http.StatusForbidden)) +} + +func TestLastOwner_SoleOwnerCannotDemote(t *testing.T) { + test.RegisterIntegration(t) + defer gatewayAccess.SetDirectoryResolver(nil) + gw := "gw-sole-demote" + owner := seed(t, gw, "sole-owner-d", roles.RoleGatewayOwner) + + svc := accessSvc(realm()) + _, aerr := svc.ChangeRole(context.Background(), gw, owner, owner, gatewayAccess.TierUser) + Expect(aerr).NotTo(BeNil()) + Expect(aerr.Status).To(Equal(http.StatusConflict)) + Expect(aerr.Message).NotTo(BeEmpty()) +} + +func TestLastOwner_SoleOwnerCannotBeRevoked(t *testing.T) { + test.RegisterIntegration(t) + defer gatewayAccess.SetDirectoryResolver(nil) + gw := "gw-sole-revoke" + owner := seed(t, gw, "sole-owner-r", roles.RoleGatewayOwner) + + svc := accessSvc(realm()) + aerr := svc.Revoke(context.Background(), gw, owner, owner) + Expect(aerr).NotTo(BeNil()) + Expect(aerr.Status).To(Equal(http.StatusConflict)) +} + +func TestRevoke_OwnerSelfWhenAnotherOwnerRemains(t *testing.T) { + test.RegisterIntegration(t) + defer gatewayAccess.SetDirectoryResolver(nil) + gw := "gw-two-owners" + a := seed(t, gw, "owner-a-two", roles.RoleGatewayOwner) + seed(t, gw, "owner-e-two", roles.RoleGatewayOwner) + + svc := accessSvc(realm()) + aerr := svc.Revoke(context.Background(), gw, a, a) + Expect(aerr).To(BeNil()) + + tier, _ := userTierOf(svc, gw, a) + Expect(tier).To(Equal("")) +} + +func TestRevoke_AdminCannotRevokeOwner(t *testing.T) { + test.RegisterIntegration(t) + defer gatewayAccess.SetDirectoryResolver(nil) + gw := "gw-admin-norevoke-owner" + owner := seed(t, gw, "owner-nr", roles.RoleGatewayOwner) + admin := seed(t, gw, "admin-nr", roles.RoleGatewayAdmin) + + svc := accessSvc(realm()) + aerr := svc.Revoke(context.Background(), gw, admin, owner) + Expect(aerr).NotTo(BeNil()) + Expect(aerr.Status).To(Equal(http.StatusForbidden)) +} + +func TestViewerCannotManage(t *testing.T) { + test.RegisterIntegration(t) + defer gatewayAccess.SetDirectoryResolver(nil) + gw := "gw-viewer-nomanage" + seed(t, gw, "owner-vn", roles.RoleGatewayOwner) + viewer := seed(t, gw, "viewer-vn", roles.RoleGatewayViewer) + + dir := realm(gatewayAccess.DirectoryUser{Username: "newbie"}) + svc := accessSvc(dir) + _, aerr := svc.Grant(context.Background(), gw, viewer, gatewayAccess.GrantInput{Username: "newbie", Role: gatewayAccess.TierUser}) + Expect(aerr).NotTo(BeNil()) + Expect(aerr.Status).To(Equal(http.StatusForbidden)) +} + +func TestIdempotentRegrant(t *testing.T) { + test.RegisterIntegration(t) + defer gatewayAccess.SetDirectoryResolver(nil) + gw := "gw-idempotent" + owner := seed(t, gw, "owner-id", roles.RoleGatewayOwner) + dir := realm(gatewayAccess.DirectoryUser{Username: "repeat"}) + svc := accessSvc(dir) + + _, aerr := svc.Grant(context.Background(), gw, owner, gatewayAccess.GrantInput{Username: "repeat", Role: gatewayAccess.TierUser}) + Expect(aerr).To(BeNil()) + _, aerr = svc.Grant(context.Background(), gw, owner, gatewayAccess.GrantInput{Username: "repeat", Role: gatewayAccess.TierUser}) + Expect(aerr).To(BeNil()) + + items, _, _, _ := svc.List(context.Background(), gw, owner, gatewayAccess.ListOptions{Page: 1, Size: 50}) + count := 0 + for _, it := range items { + if it.Username == "repeat" { + count++ + } + } + Expect(count).To(Equal(1)) +} + +func TestDirectorySearch_Authorization(t *testing.T) { + test.RegisterIntegration(t) + defer gatewayAccess.SetDirectoryResolver(nil) + gw := "gw-dir-authz" + admin := seed(t, gw, "admin-dir", roles.RoleGatewayAdmin) + viewer := seed(t, gw, "viewer-dir", roles.RoleGatewayViewer) + + dir := realm( + gatewayAccess.DirectoryUser{Username: "dana", Name: "Dana"}, + gatewayAccess.DirectoryUser{Username: "dale", Name: "Dale"}, + ) + svc := accessSvc(dir) + + // Viewer cannot search. + _, aerr := svc.SearchDirectory(context.Background(), gw, viewer, "da") + Expect(aerr).NotTo(BeNil()) + Expect(aerr.Status).To(Equal(http.StatusForbidden)) + + // Admin can. + candidates, aerr := svc.SearchDirectory(context.Background(), gw, admin, "da") + Expect(aerr).To(BeNil()) + Expect(len(candidates)).To(Equal(2)) +} + +// userTierOf lists access and returns the target's tier via capabilities-style +// lookup (the caller is the subject here). +func userTierOf(svc gatewayAccess.Service, gw, userID string) (string, error) { + items, _, _, aerr := svc.List(context.Background(), gw, userID, gatewayAccess.ListOptions{Page: 1, Size: 100}) + if aerr != nil { + return "", aerr + } + for _, it := range items { + if it.UserID == userID { + return it.Role, nil + } + } + return "", nil +} diff --git a/components/api-server/plugins/gatewayAccess/plugin.go b/components/api-server/plugins/gatewayAccess/plugin.go new file mode 100644 index 000000000..ccd45578a --- /dev/null +++ b/components/api-server/plugins/gatewayAccess/plugin.go @@ -0,0 +1,60 @@ +package gatewayAccess + +import ( + "net/http" + + "github.com/gorilla/mux" + + "github.com/openshift-online/hypershell/components/api-server/plugins/roleBindings" + "github.com/openshift-online/hypershell/components/api-server/plugins/users" + "github.com/openshift-online/rh-trex-ai/components/api-server/pkg/auth" + "github.com/openshift-online/rh-trex-ai/components/api-server/pkg/db" + "github.com/openshift-online/rh-trex-ai/components/api-server/pkg/environments" + "github.com/openshift-online/rh-trex-ai/components/api-server/pkg/registry" + pkgserver "github.com/openshift-online/rh-trex-ai/components/api-server/pkg/server" +) + +type ServiceLocator func() Service + +func NewServiceLocator(env *environments.Env) ServiceLocator { + return func() Service { + return NewService( + newDao(&env.Database.SessionFactory), + roleBindings.Service(&env.Services), + users.Service(&env.Services), + newDirectoryResolverFromEnvironment(), + db.NewAdvisoryLockFactory(env.Database.SessionFactory), + ) + } +} + +func ServiceFrom(s *environments.Services) Service { + if s == nil { + return nil + } + if obj := s.GetService("GatewayAccess"); obj != nil { + return obj.(ServiceLocator)() + } + return nil +} + +func init() { + registry.RegisterService("GatewayAccess", func(env interface{}) interface{} { + return NewServiceLocator(env.(*environments.Env)) + }) + + pkgserver.RegisterRoutes("gatewayAccess", func(apiV1Router *mux.Router, services pkgserver.ServicesInterface, authMiddleware environments.JWTMiddleware, authzMiddleware auth.AuthorizationMiddleware) { + envServices := services.(*environments.Services) + h := NewHandler(ServiceFrom(envServices)) + router := apiV1Router.PathPrefix("/gateways/{gateway_id}/access").Subrouter() + router.HandleFunc("", h.List).Methods(http.MethodGet) + router.HandleFunc("", h.Grant).Methods(http.MethodPost) + // /directory is registered before /{user_id} so the literal segment is + // not captured as a user id. + router.HandleFunc("/directory", h.SearchDirectory).Methods(http.MethodGet) + router.HandleFunc("/{user_id}", h.ChangeRole).Methods(http.MethodPatch) + router.HandleFunc("/{user_id}", h.Revoke).Methods(http.MethodDelete) + router.Use(authMiddleware.AuthenticateAccountJWT) + router.Use(authzMiddleware.AuthorizeApi) + }) +} diff --git a/components/api-server/plugins/gatewayAccess/presenter.go b/components/api-server/plugins/gatewayAccess/presenter.go new file mode 100644 index 000000000..b6f09f312 --- /dev/null +++ b/components/api-server/plugins/gatewayAccess/presenter.go @@ -0,0 +1,54 @@ +package gatewayAccess + +import "time" + +type grantItemResponse struct { + RoleBindingID string `json:"role_binding_id"` + UserID string `json:"user_id"` + Username string `json:"username"` + Name *string `json:"name,omitempty"` + Email *string `json:"email,omitempty"` + Role string `json:"role"` + IsCreator bool `json:"is_creator"` + GrantedAt time.Time `json:"granted_at"` +} + +type capabilitiesResponse struct { + CallerRole string `json:"caller_role,omitempty"` + CanManageAccess bool `json:"can_manage_access"` + CanManageOwners bool `json:"can_manage_owners"` +} + +type listResponse struct { + Page int `json:"page"` + Size int `json:"size"` + Total int64 `json:"total"` + Capabilities capabilitiesResponse `json:"capabilities"` + Items []grantItemResponse `json:"items"` +} + +type directoryCandidateResponse struct { + Username string `json:"username"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + Subject string `json:"subject,omitempty"` +} + +type directoryResponse struct { + Items []directoryCandidateResponse `json:"items"` +} + +// The response types share field names, types, and order with their domain +// counterparts; a direct conversion keeps the JSON tags while avoiding a +// field-by-field copy. A later divergence turns into a compile error here. +func presentGrant(item GrantItem) grantItemResponse { + return grantItemResponse(item) +} + +func presentCapabilities(c Capabilities) capabilitiesResponse { + return capabilitiesResponse(c) +} + +func presentCandidate(c DirectoryCandidate) directoryCandidateResponse { + return directoryCandidateResponse(c) +} diff --git a/components/api-server/plugins/gatewayAccess/service.go b/components/api-server/plugins/gatewayAccess/service.go new file mode 100644 index 000000000..0ac802750 --- /dev/null +++ b/components/api-server/plugins/gatewayAccess/service.go @@ -0,0 +1,384 @@ +package gatewayAccess + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/openshift-online/hypershell/components/api-server/plugins/roleBindings" + "github.com/openshift-online/hypershell/components/api-server/plugins/roles" + "github.com/openshift-online/hypershell/components/api-server/plugins/users" + "github.com/openshift-online/rh-trex-ai/components/api-server/pkg/db" +) + +// Console access tiers. The management plane distinguishes three per-gateway +// roles; the console and gateway distinguish only the tiers below. +const ( + TierOwner = "owner" + TierAdmin = "admin" + TierUser = "user" +) + +const accessLockType db.LockType = "gateway_access" + +func tierToRoleName(tier string) string { + switch tier { + case TierOwner: + return roles.RoleGatewayOwner + case TierAdmin: + return roles.RoleGatewayAdmin + case TierUser: + return roles.RoleGatewayViewer + default: + return "" + } +} + +func roleNameToTier(name string) string { + switch name { + case roles.RoleGatewayOwner: + return TierOwner + case roles.RoleGatewayAdmin: + return TierAdmin + case roles.RoleGatewayViewer: + return TierUser + default: + return "" + } +} + +var tierRank = map[string]int{TierUser: 1, TierAdmin: 2, TierOwner: 3} + +// ListOptions follows the shared list contract. Role is a console tier filter +// (owner/admin/user); Search is a literal substring over username and name. +type ListOptions struct { + Page int + Size int + Search string + Role string + Sort string + Order string +} + +// GrantInput identifies the target of a grant by directory identity (GAM-04). +type GrantInput struct { + Username string + Subject string + Role string +} + +// GrantItem is the enriched access grant returned to callers (GAM-03 shape). +type GrantItem struct { + RoleBindingID string + UserID string + Username string + Name *string + Email *string + Role string + IsCreator bool + GrantedAt time.Time +} + +// Capabilities tells the console what the caller may do, so it can gate controls +// (GAM-UI-07/08). The server remains authoritative. +type Capabilities struct { + CallerRole string + CanManageAccess bool + CanManageOwners bool +} + +// DirectoryCandidate is a realm user returned by the directory search (GAM-09). +type DirectoryCandidate struct { + Username string + Name string + Email string + Subject string +} + +type Service interface { + List(ctx context.Context, gatewayID, callerUserID string, opts ListOptions) ([]GrantItem, int64, Capabilities, *APIError) + Grant(ctx context.Context, gatewayID, callerUserID string, input GrantInput) (GrantItem, *APIError) + ChangeRole(ctx context.Context, gatewayID, callerUserID, targetUserID, tier string) (GrantItem, *APIError) + Revoke(ctx context.Context, gatewayID, callerUserID, targetUserID string) *APIError + SearchDirectory(ctx context.Context, gatewayID, callerUserID, query string) ([]DirectoryCandidate, *APIError) +} + +type service struct { + dao *dao + bindings roleBindings.RoleBindingService + users users.UserService + directory DirectoryResolver + locks db.LockFactory +} + +func NewService(d *dao, bindings roleBindings.RoleBindingService, userService users.UserService, directory DirectoryResolver, locks db.LockFactory) Service { + if directory == nil { + directory = unconfiguredResolver{} + } + return &service{dao: d, bindings: bindings, users: userService, directory: directory, locks: locks} +} + +var _ Service = &service{} + +// userTier returns the user's highest gateway tier on gatewayID (owner > admin +// > user), or "" if the user has no gateway-scoped binding there. +func (s *service) userTier(ctx context.Context, userID, gatewayID string) (string, *APIError) { + summaries, err := s.bindings.FindBindingsByUserID(ctx, userID) + if err != nil { + return "", serverError("unable to resolve access") + } + best := "" + for _, b := range summaries { + if b.Scope != roleBindings.ScopeGateway || b.GatewayID == nil || *b.GatewayID != gatewayID { + continue + } + t := roleNameToTier(b.RoleName) + if t != "" && tierRank[t] > tierRank[best] { + best = t + } + } + return best, nil +} + +func (s *service) List(ctx context.Context, gatewayID, callerUserID string, opts ListOptions) ([]GrantItem, int64, Capabilities, *APIError) { + rows, total, err := s.dao.list(ctx, gatewayID, opts) + if err != nil { + return nil, 0, Capabilities{}, serverError("unable to list gateway access") + } + creatorID, err := s.dao.creatorUserID(ctx, gatewayID) + if err != nil { + return nil, 0, Capabilities{}, serverError("unable to resolve gateway creator") + } + items := make([]GrantItem, 0, len(rows)) + for _, r := range rows { + items = append(items, grantItemFromRow(r, creatorID)) + } + tier, aerr := s.userTier(ctx, callerUserID, gatewayID) + if aerr != nil { + return nil, 0, Capabilities{}, aerr + } + caps := Capabilities{ + CallerRole: tier, + CanManageAccess: tier == TierOwner || tier == TierAdmin, + CanManageOwners: tier == TierOwner, + } + return items, total, caps, nil +} + +func (s *service) Grant(ctx context.Context, gatewayID, callerUserID string, input GrantInput) (GrantItem, *APIError) { + if tierToRoleName(input.Role) == "" { + return GrantItem{}, validationProblem("role must be one of owner, admin, user") + } + if strings.TrimSpace(input.Username) == "" { + return GrantItem{}, validationProblem("username is required") + } + if aerr := s.authorizeManage(ctx, callerUserID, gatewayID, input.Role, ""); aerr != nil { + return GrantItem{}, aerr + } + + // Resolve the identity against the Keycloak realm before any write (GAM-04). + // Unknown -> 404, no pre-provision and no binding. + dirUser, found, derr := s.directory.Resolve(ctx, input.Username) + if derr != nil { + if errors.Is(derr, ErrDirectoryUnavailable) { + return GrantItem{}, serviceUnavailable("the identity directory is not available") + } + return GrantItem{}, serviceUnavailable("directory lookup failed") + } + if !found { + return GrantItem{}, notFound(fmt.Sprintf("user %q was not found in the identity directory and cannot be granted access", input.Username)) + } + + // Pre-provision the HyperShell User from the directory record (upsert by + // username), so the binding references a real user_id and the control plane + // can resolve the Keycloak user by username. + targetUserID, uerr := s.users.UpsertByUsername(ctx, strings.ToLower(dirUser.Username), optStr(dirUser.Email), optStr(dirUser.Name)) + if uerr != nil { + return GrantItem{}, serverError("unable to provision user record") + } + + release, aerr := s.lock(ctx, gatewayID) + if aerr != nil { + return GrantItem{}, aerr + } + defer release() + + // A grant that converges an existing owner to a lower tier is a change + // (GAM-10); apply last-owner protection. + if aerr := s.guardLastOwner(ctx, gatewayID, targetUserID, input.Role); aerr != nil { + return GrantItem{}, aerr + } + if _, serr := s.bindings.SetGatewayRole(ctx, targetUserID, gatewayID, tierToRoleName(input.Role)); serr != nil { + return GrantItem{}, fromServiceError(serr) + } + return s.grantItem(ctx, gatewayID, targetUserID) +} + +func (s *service) ChangeRole(ctx context.Context, gatewayID, callerUserID, targetUserID, tier string) (GrantItem, *APIError) { + if tierToRoleName(tier) == "" { + return GrantItem{}, validationProblem("role must be one of owner, admin, user") + } + release, aerr := s.lock(ctx, gatewayID) + if aerr != nil { + return GrantItem{}, aerr + } + defer release() + + current, aerr := s.userTier(ctx, targetUserID, gatewayID) + if aerr != nil { + return GrantItem{}, aerr + } + if current == "" { + return GrantItem{}, notFound("user has no access to this gateway") + } + if aerr := s.authorizeManage(ctx, callerUserID, gatewayID, tier, current); aerr != nil { + return GrantItem{}, aerr + } + if aerr := s.guardLastOwner(ctx, gatewayID, targetUserID, tier); aerr != nil { + return GrantItem{}, aerr + } + if _, serr := s.bindings.SetGatewayRole(ctx, targetUserID, gatewayID, tierToRoleName(tier)); serr != nil { + return GrantItem{}, fromServiceError(serr) + } + return s.grantItem(ctx, gatewayID, targetUserID) +} + +func (s *service) Revoke(ctx context.Context, gatewayID, callerUserID, targetUserID string) *APIError { + release, aerr := s.lock(ctx, gatewayID) + if aerr != nil { + return aerr + } + defer release() + + current, aerr := s.userTier(ctx, targetUserID, gatewayID) + if aerr != nil { + return aerr + } + if current == "" { + return notFound("user has no access to this gateway") + } + // Revoking to no access is an owner-tier operation when the target is an + // owner (the "new tier" is none, i.e. not owner). + if aerr := s.authorizeManage(ctx, callerUserID, gatewayID, "", current); aerr != nil { + return aerr + } + if aerr := s.guardLastOwner(ctx, gatewayID, targetUserID, ""); aerr != nil { + return aerr + } + if _, serr := s.bindings.RevokeGatewayAccess(ctx, targetUserID, gatewayID); serr != nil { + return fromServiceError(serr) + } + return nil +} + +func (s *service) SearchDirectory(ctx context.Context, gatewayID, callerUserID, query string) ([]DirectoryCandidate, *APIError) { + tier, aerr := s.userTier(ctx, callerUserID, gatewayID) + if aerr != nil { + return nil, aerr + } + if tier != TierOwner && tier != TierAdmin { + return nil, forbidden("only gateway owners and admins can search the directory") + } + const limit = 50 + candidates, err := s.directory.Search(ctx, query, limit) + if err != nil { + if errors.Is(err, ErrDirectoryUnavailable) { + return nil, serviceUnavailable("the identity directory is not available") + } + return nil, serviceUnavailable("directory search failed") + } + out := make([]DirectoryCandidate, 0, len(candidates)) + for _, c := range candidates { + out = append(out, DirectoryCandidate(c)) + } + return out, nil +} + +// authorizeManage enforces GAM-08: the caller must be owner or admin, and any +// operation touching the Owner tier (assigning owner, or changing/revoking a +// user who currently holds owner) requires the caller to be an owner. +// newTier is "" for revoke; currentTier is "" for a fresh grant. +func (s *service) authorizeManage(ctx context.Context, callerUserID, gatewayID, newTier, currentTier string) *APIError { + caller, aerr := s.userTier(ctx, callerUserID, gatewayID) + if aerr != nil { + return aerr + } + if caller != TierOwner && caller != TierAdmin { + return forbidden("only gateway owners and admins can manage access") + } + if (newTier == TierOwner || currentTier == TierOwner) && caller != TierOwner { + return forbidden("only gateway owners can assign, change, or revoke the owner role") + } + return nil +} + +// guardLastOwner rejects an operation (demote or revoke) that would remove the +// last remaining owner (GAM-07). newTier is "" for revoke. +func (s *service) guardLastOwner(ctx context.Context, gatewayID, targetUserID, newTier string) *APIError { + owners, err := s.dao.ownerUserIDs(ctx, gatewayID) + if err != nil { + return serverError("unable to evaluate gateway owners") + } + isOwner := false + for _, id := range owners { + if id == targetUserID { + isOwner = true + break + } + } + if !isOwner || newTier == TierOwner { + return nil + } + if len(owners) <= 1 { + return conflictErr("this gateway must keep at least one owner; assign another owner before demoting or removing this one") + } + return nil +} + +func (s *service) grantItem(ctx context.Context, gatewayID, userID string) (GrantItem, *APIError) { + row, found, err := s.dao.getForUser(ctx, gatewayID, userID) + if err != nil { + return GrantItem{}, serverError("unable to load grant") + } + if !found { + return GrantItem{}, serverError("grant not found after write") + } + creatorID, err := s.dao.creatorUserID(ctx, gatewayID) + if err != nil { + return GrantItem{}, serverError("unable to resolve gateway creator") + } + return grantItemFromRow(row, creatorID), nil +} + +func grantItemFromRow(r accessRow, creatorID string) GrantItem { + return GrantItem{ + RoleBindingID: r.RoleBindingID, + UserID: r.UserID, + Username: r.Username, + Name: r.Name, + Email: r.Email, + Role: roleNameToTier(r.RoleName), + IsCreator: r.UserID == creatorID && r.RoleName == roles.RoleGatewayOwner, + GrantedAt: r.GrantedAt, + } +} + +func (s *service) lock(ctx context.Context, gatewayID string) (func(), *APIError) { + if s.locks == nil { + return func() {}, nil + } + owner, err := s.locks.NewAdvisoryLock(ctx, "gateway-access-"+gatewayID, accessLockType) + if err != nil { + return nil, serverError("unable to acquire access lock") + } + return func() { s.locks.Unlock(ctx, owner) }, nil +} + +func optStr(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/components/api-server/plugins/gatewayAccess/testmain_test.go b/components/api-server/plugins/gatewayAccess/testmain_test.go new file mode 100644 index 000000000..1b68f4945 --- /dev/null +++ b/components/api-server/plugins/gatewayAccess/testmain_test.go @@ -0,0 +1,25 @@ +package gatewayAccess_test + +import ( + "flag" + "os" + "runtime" + "testing" + + "github.com/golang/glog" + + "github.com/openshift-online/hypershell/components/api-server/test" + + // Register the rbac plugin so UserProvisioningMiddleware is wired for the + // HTTP server the helper starts. + _ "github.com/openshift-online/hypershell/components/api-server/plugins/rbac" +) + +func TestMain(m *testing.M) { + flag.Parse() + glog.Infof("Starting gatewayAccess integration test using go version %s", runtime.Version()) + helper := test.NewHelper(&testing.T{}) + exitCode := m.Run() + helper.Teardown() + os.Exit(exitCode) +} diff --git a/components/api-server/plugins/roleBindings/dao.go b/components/api-server/plugins/roleBindings/dao.go index c443bcc43..c87605af5 100644 --- a/components/api-server/plugins/roleBindings/dao.go +++ b/components/api-server/plugins/roleBindings/dao.go @@ -14,8 +14,10 @@ type RoleBindingDao interface { Get(ctx context.Context, id string) (*RoleBinding, error) GetUnscoped(ctx context.Context, id string) (*RoleBinding, error) Create(ctx context.Context, rb *RoleBinding) (*RoleBinding, error) + Update(ctx context.Context, rb *RoleBinding) (*RoleBinding, error) Delete(ctx context.Context, id string) error FindByUserID(ctx context.Context, userID string) (RoleBindingList, error) + FindByGatewayID(ctx context.Context, gatewayID string) (RoleBindingList, error) FindByIDs(ctx context.Context, ids []string) (RoleBindingList, error) FindGatewayIDsByUserID(ctx context.Context, userID string) ([]string, error) FindOwnerUsernamesByGatewayIDs(ctx context.Context, gatewayIDs []string) (map[string]string, error) @@ -50,6 +52,18 @@ func (d *sqlRoleBindingDao) Create(ctx context.Context, rb *RoleBinding) (*RoleB return rb, nil } +// Update persists an in-place change to an existing binding (e.g. a role_id +// swap for a role change). Associations are omitted so only the binding row is +// written. +func (d *sqlRoleBindingDao) Update(ctx context.Context, rb *RoleBinding) (*RoleBinding, error) { + g2 := (*d.sessionFactory).New(ctx) + if err := g2.Omit(clause.Associations).Save(rb).Error; err != nil { + db.MarkForRollback(ctx, err) + return nil, err + } + return rb, nil +} + func (d *sqlRoleBindingDao) Delete(ctx context.Context, id string) error { g2 := (*d.sessionFactory).New(ctx) if err := g2.Omit(clause.Associations).Delete(&RoleBinding{Meta: api.Meta{ID: id}}).Error; err != nil { @@ -77,6 +91,15 @@ func (d *sqlRoleBindingDao) FindByUserID(ctx context.Context, userID string) (Ro return bindings, nil } +func (d *sqlRoleBindingDao) FindByGatewayID(ctx context.Context, gatewayID string) (RoleBindingList, error) { + g2 := (*d.sessionFactory).New(ctx) + bindings := RoleBindingList{} + if err := g2.Where("gateway_id = ?", gatewayID).Find(&bindings).Error; err != nil { + return nil, err + } + return bindings, nil +} + func (d *sqlRoleBindingDao) FindByIDs(ctx context.Context, ids []string) (RoleBindingList, error) { g2 := (*d.sessionFactory).New(ctx) bindings := RoleBindingList{} diff --git a/components/api-server/plugins/roleBindings/service.go b/components/api-server/plugins/roleBindings/service.go index 5ff2782da..0a08924e2 100644 --- a/components/api-server/plugins/roleBindings/service.go +++ b/components/api-server/plugins/roleBindings/service.go @@ -3,6 +3,7 @@ package roleBindings import ( "context" "fmt" + "sort" "github.com/golang/glog" "github.com/openshift-online/hypershell/components/api-server/pkg/rbac" @@ -20,6 +21,19 @@ type RoleBindingService interface { Delete(ctx context.Context, id string) *errors.ServiceError CreateGatewayOwnerBinding(ctx context.Context, userID string, gatewayID string) error FindBindingsByUserID(ctx context.Context, userID string) ([]rbac.BindingSummary, error) + BindingsForGateway(ctx context.Context, gatewayID string) (RoleBindingList, *errors.ServiceError) + // SetGatewayRole converges the user's bindings on gatewayID to exactly one + // binding for roleName (one of gateway:owner/admin/viewer), in place where + // possible (GAM-05, GAM-10). It emits an UPDATED watch event when an existing + // single binding's role_id is changed in place (no zero-access window), a + // CREATED event when a new binding is made, and DELETED events for any surplus + // bindings removed during convergence. It performs NO caller authorization; + // the access facade authorizes before calling. + SetGatewayRole(ctx context.Context, userID string, gatewayID string, roleName string) (*RoleBinding, *errors.ServiceError) + // RevokeGatewayAccess deletes all of the user's gateway-scoped bindings on + // gatewayID (one DELETED event each) and returns the number removed. It + // performs NO caller authorization. + RevokeGatewayAccess(ctx context.Context, userID string, gatewayID string) (int, *errors.ServiceError) FindByUserID(ctx context.Context, userID string) (RoleBindingList, *errors.ServiceError) FindGatewayIDsByUserID(ctx context.Context, userID string) ([]string, *errors.ServiceError) FindOwnerUsernamesByGatewayIDs(ctx context.Context, gatewayIDs []string) (map[string]string, error) @@ -273,6 +287,123 @@ func (s *sqlRoleBindingService) Delete(ctx context.Context, id string) *errors.S return nil } +func (s *sqlRoleBindingService) BindingsForGateway(ctx context.Context, gatewayID string) (RoleBindingList, *errors.ServiceError) { + bindings, err := s.rbDao.FindByGatewayID(ctx, gatewayID) + if err != nil { + return nil, errors.GeneralError("Unable to get gateway role bindings: %s", err) + } + return bindings, nil +} + +// gatewayBindingsForUser returns the user's gateway-scoped bindings on gatewayID. +func (s *sqlRoleBindingService) gatewayBindingsForUser(ctx context.Context, userID, gatewayID string) (RoleBindingList, error) { + all, err := s.rbDao.FindByUserID(ctx, userID) + if err != nil { + return nil, err + } + out := RoleBindingList{} + for _, b := range all { + if b.Scope == ScopeGateway && b.GatewayID != nil && *b.GatewayID == gatewayID { + out = append(out, b) + } + } + return out, nil +} + +func (s *sqlRoleBindingService) SetGatewayRole(ctx context.Context, userID, gatewayID, roleName string) (*RoleBinding, *errors.ServiceError) { + if roleName != roles.RoleGatewayOwner && roleName != roles.RoleGatewayAdmin && roleName != roles.RoleGatewayViewer { + return nil, errors.Validation("invalid gateway role: %q", roleName) + } + targetRole, roleErr := s.roleDao.GetByName(ctx, roleName) + if roleErr != nil { + return nil, errors.Validation("invalid role %q: %s", roleName, roleErr) + } + + existing, err := s.gatewayBindingsForUser(ctx, userID, gatewayID) + if err != nil { + return nil, errors.GeneralError("unable to look up existing bindings: %s", err) + } + + // No binding yet: create one (CREATED event). + if len(existing) == 0 { + uid := userID + gid := gatewayID + rb := &RoleBinding{RoleID: targetRole.ID, Scope: ScopeGateway, UserID: &uid, GatewayID: &gid} + rb.CaptureTraceContext(ctx) + created, createErr := s.rbDao.Create(ctx, rb) + if createErr != nil { + return nil, services.HandleCreateError("RoleBinding", createErr) + } + if evErr := s.emitBindingEvent(ctx, created.ID, api.CreateEventType); evErr != nil { + return nil, evErr + } + return created, nil + } + + // Converge to exactly one binding (GAM-10): keep the earliest-created, + // delete any surplus so a user never accumulates conflicting bindings. + sort.Slice(existing, func(i, j int) bool { + if existing[i].CreatedAt.Equal(existing[j].CreatedAt) { + return existing[i].ID < existing[j].ID + } + return existing[i].CreatedAt.Before(existing[j].CreatedAt) + }) + keep := existing[0] + for _, extra := range existing[1:] { + if delErr := s.rbDao.Delete(ctx, extra.ID); delErr != nil { + return nil, services.HandleDeleteError("RoleBinding", errors.GeneralError("unable to delete duplicate binding: %s", delErr)) + } + if evErr := s.emitBindingEvent(ctx, extra.ID, api.DeleteEventType); evErr != nil { + return nil, evErr + } + } + + // Already the target role: idempotent no-op success (GAM-10). + if keep.RoleID == targetRole.ID { + return keep, nil + } + + // Change in place (GAM-05): a single UPDATED event, no zero-access window. + keep.RoleID = targetRole.ID + keep.CaptureTraceContext(ctx) + updated, updErr := s.rbDao.Update(ctx, keep) + if updErr != nil { + return nil, services.HandleUpdateError("RoleBinding", updErr) + } + if evErr := s.emitBindingEvent(ctx, updated.ID, api.UpdateEventType); evErr != nil { + return nil, evErr + } + return updated, nil +} + +func (s *sqlRoleBindingService) RevokeGatewayAccess(ctx context.Context, userID, gatewayID string) (int, *errors.ServiceError) { + existing, err := s.gatewayBindingsForUser(ctx, userID, gatewayID) + if err != nil { + return 0, errors.GeneralError("unable to look up existing bindings: %s", err) + } + for _, b := range existing { + if delErr := s.rbDao.Delete(ctx, b.ID); delErr != nil { + return 0, services.HandleDeleteError("RoleBinding", errors.GeneralError("unable to delete binding: %s", delErr)) + } + if evErr := s.emitBindingEvent(ctx, b.ID, api.DeleteEventType); evErr != nil { + return 0, evErr + } + } + return len(existing), nil +} + +func (s *sqlRoleBindingService) emitBindingEvent(ctx context.Context, sourceID string, eventType api.EventType) *errors.ServiceError { + _, evErr := s.events.Create(ctx, &api.Event{ + Source: "RoleBindings", + SourceID: sourceID, + EventType: eventType, + }) + if evErr != nil { + return evErr + } + return nil +} + func (s *sqlRoleBindingService) FindBindingsByUserID(ctx context.Context, userID string) ([]rbac.BindingSummary, error) { bindings, err := s.rbDao.FindByUserID(ctx, userID) if err != nil { diff --git a/components/api-server/plugins/roles/migration.go b/components/api-server/plugins/roles/migration.go index 324d114c9..e0bd2261d 100644 --- a/components/api-server/plugins/roles/migration.go +++ b/components/api-server/plugins/roles/migration.go @@ -144,10 +144,21 @@ var builtInRoleSeeds = []roleSeed{ { Name: RoleGatewayOwner, DisplayName: "Gateway Owner", - Description: "Full CRUD on one gateway; can grant owner and viewer to others", + Description: "Full CRUD on one gateway; can grant owner, admin, and viewer to others", Permissions: map[string]interface{}{ - "gateways": []string{"read", "update", "delete"}, - "role_bindings": []string{"create", "read", "delete", "list"}, + "gateways": []string{"read", "update", "delete"}, + "role_bindings": []string{"create", "read", "update", "delete", "list"}, + "service_accounts": []string{"create", "read", "delete", "list"}, + }, + }, + { + Name: RoleGatewayAdmin, + DisplayName: "Gateway Admin", + Description: "Granted administrator: read/update one gateway (no delete); can grant admin and viewer to others", + Permissions: map[string]interface{}{ + "gateways": []string{"read", "update"}, + "role_bindings": []string{"create", "read", "update", "delete", "list"}, + "service_accounts": []string{"create", "read", "delete", "list"}, }, }, { @@ -252,6 +263,55 @@ func migrationSeedManagedClusterRegistrarRole() *gormigrate.Migration { } } +// migrationAddGatewayAdminRole seeds the gateway:admin built-in role +// idempotently (GAM-12). gateway:admin is a per-gateway, DB-assigned role (like +// gateway:owner and gateway:viewer); it is NOT JWT-synced. Re-running is safe, +// and no existing binding is touched. +func migrationAddGatewayAdminRole() *gormigrate.Migration { + type Role struct { + db.Model + Name string `gorm:"uniqueIndex"` + DisplayName *string + Description *string + Permissions *string `gorm:"type:jsonb"` + BuiltIn bool + } + + return &gormigrate.Migration{ + ID: "2026100600000001", + Migrate: func(tx *gorm.DB) error { + var existing Role + if err := tx.Where("name = ?", RoleGatewayAdmin).First(&existing).Error; err == nil { + return nil + } + permissions := map[string]interface{}{ + "gateways": []string{"read", "update"}, + "role_bindings": []string{"create", "read", "update", "delete", "list"}, + "service_accounts": []string{"create", "read", "delete", "list"}, + } + permJSON, err := json.Marshal(permissions) + if err != nil { + return err + } + permStr := string(permJSON) + displayName := "Gateway Admin" + description := "Granted administrator: read/update one gateway (no delete); can grant admin and viewer to others" + role := Role{ + Model: db.Model{ID: api.NewID()}, + Name: RoleGatewayAdmin, + DisplayName: &displayName, + Description: &description, + Permissions: &permStr, + BuiltIn: true, + } + return tx.Create(&role).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Where("name = ?", RoleGatewayAdmin).Delete(&Role{}).Error + }, + } +} + func SeedRoles(ctx context.Context, dao RoleDao) error { for _, seed := range builtInRoleSeeds { _, err := dao.GetByName(ctx, seed.Name) diff --git a/components/api-server/plugins/roles/model.go b/components/api-server/plugins/roles/model.go index 1372207de..b48d9ec0c 100644 --- a/components/api-server/plugins/roles/model.go +++ b/components/api-server/plugins/roles/model.go @@ -36,6 +36,7 @@ const ( RolePlatformAdmin = "platform:admin" RoleGatewayCreator = "gateway:creator" RoleGatewayOwner = "gateway:owner" + RoleGatewayAdmin = "gateway:admin" RoleGatewayViewer = "gateway:viewer" RoleManagedClusterRegistrar = "managed-cluster-registrar" ) diff --git a/components/api-server/plugins/roles/plugin.go b/components/api-server/plugins/roles/plugin.go index a389f7fb7..a19180b49 100644 --- a/components/api-server/plugins/roles/plugin.go +++ b/components/api-server/plugins/roles/plugin.go @@ -60,4 +60,5 @@ func init() { db.RegisterMigration(migrationSeedBuiltInRoles()) db.RegisterMigration(migrationAddPlatformAdminRole()) db.RegisterMigration(migrationSeedManagedClusterRegistrarRole()) + db.RegisterMigration(migrationAddGatewayAdminRole()) } diff --git a/components/api-server/plugins/serviceAccounts/service.go b/components/api-server/plugins/serviceAccounts/service.go index 0a69b94dc..2967cf028 100644 --- a/components/api-server/plugins/serviceAccounts/service.go +++ b/components/api-server/plugins/serviceAccounts/service.go @@ -953,8 +953,10 @@ func (s *service) access(ctx context.Context, gatewayID, userID string) (Access, if binding.Scope != "gateway" || binding.GatewayID == nil || *binding.GatewayID != gatewayID { continue } - if binding.RoleName == "gateway:owner" { - return Access{CanCreate: true, CanManageAll: true, AllowedRoles: []string{RoleUser, RoleAdmin}, Role: "gateway:owner"}, nil + // A granted admin is capped identically to an owner (GAM-11): it may + // create and manage all service accounts and select either OpenShell role. + if binding.RoleName == "gateway:owner" || binding.RoleName == "gateway:admin" { + return Access{CanCreate: true, CanManageAll: true, AllowedRoles: []string{RoleUser, RoleAdmin}, Role: binding.RoleName}, nil } if binding.RoleName == "gateway:viewer" { result = Access{CanCreate: true, CanManageAll: false, AllowedRoles: []string{RoleUser}, Role: "gateway:viewer"} diff --git a/components/api-server/proto/hypershell/provisioner/v1/service_accounts.proto b/components/api-server/proto/hypershell/provisioner/v1/service_accounts.proto index 78118c764..bfd5a6c8c 100644 --- a/components/api-server/proto/hypershell/provisioner/v1/service_accounts.proto +++ b/components/api-server/proto/hypershell/provisioner/v1/service_accounts.proto @@ -92,3 +92,37 @@ message ManagedClient { message ListManagedResponse { repeated ManagedClient clients = 1; } + +// DirectoryService serves the control-plane-maintained projection of Keycloak +// realm users (GAM-09) to the API server over the same private in-cluster +// channel as the provisioner. The API server never reads the Keycloak admin +// Secret; it reads this projection. +service DirectoryService { + rpc SearchDirectory(SearchDirectoryRequest) returns (SearchDirectoryResponse); + rpc ResolveUser(ResolveUserRequest) returns (ResolveUserResponse); +} + +message DirectoryUser { + string username = 1; + string name = 2; + string email = 3; + string subject = 4; +} + +message SearchDirectoryRequest { + string query = 1; + int32 limit = 2; +} + +message SearchDirectoryResponse { + repeated DirectoryUser users = 1; +} + +message ResolveUserRequest { + string username = 1; +} + +message ResolveUserResponse { + DirectoryUser user = 1; + bool found = 2; +} diff --git a/components/cli/cmd/hsctl/create/cmd.go b/components/cli/cmd/hsctl/create/cmd.go index dbb3e17dd..de0c30554 100644 --- a/components/cli/cmd/hsctl/create/cmd.go +++ b/components/cli/cmd/hsctl/create/cmd.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/create/gateway" + "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/create/gatewayAccess" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/create/gatewayNetwork" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/create/gatewayRelease" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/create/managedCluster" @@ -21,6 +22,7 @@ var Cmd = &cobra.Command{ func init() { Cmd.AddCommand(gateway.Cmd) + Cmd.AddCommand(gatewayAccess.Cmd) Cmd.AddCommand(gatewayNetwork.Cmd) Cmd.AddCommand(gatewayRelease.Cmd) Cmd.AddCommand(managedCluster.Cmd) diff --git a/components/cli/cmd/hsctl/create/gatewayAccess/cmd.go b/components/cli/cmd/hsctl/create/gatewayAccess/cmd.go new file mode 100644 index 000000000..3e55a772e --- /dev/null +++ b/components/cli/cmd/hsctl/create/gatewayAccess/cmd.go @@ -0,0 +1,71 @@ +package gatewayAccess + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "github.com/spf13/cobra" + + "github.com/openshift-online/hypershell/components/cli/pkg/config" + "github.com/openshift-online/hypershell/components/cli/pkg/connection" + "github.com/openshift-online/hypershell/components/cli/pkg/gatewayaccess" +) + +var args struct { + gatewayID string + user string + role string + output string +} + +var Cmd = &cobra.Command{ + Use: "gatewayAccess [flags]", + Aliases: []string{"gateway-access"}, + Short: "Grant a user access to a gateway", + Long: "Grant a user owner, admin, or user access to a gateway. The user must exist in the identity directory.", + Args: cobra.NoArgs, + RunE: run, +} + +func init() { + flags := Cmd.Flags() + flags.StringVar(&args.gatewayID, "gateway-id", "", "Gateway ID.") + flags.StringVar(&args.user, "user", "", "Target username from the identity directory.") + flags.StringVar(&args.role, "role", "user", "Access role: owner, admin, or user.") + flags.StringVarP(&args.output, "output", "o", "json", "Structured output format (json).") +} + +func run(cmd *cobra.Command, _ []string) error { + if args.gatewayID == "" { + return fmt.Errorf("--gateway-id is required") + } + if args.user == "" { + return fmt.Errorf("--user is required") + } + if err := gatewayaccess.ValidateRole(args.role); err != nil { + return err + } + if err := gatewayaccess.ValidateOutput(args.output); err != nil { + return err + } + body, err := json.Marshal(map[string]any{"username": args.user, "role": args.role}) + if err != nil { + return fmt.Errorf("can't encode request: %w", err) + } + cfg, err := config.Load() + if err != nil { + return err + } + conn, err := connection.NewConnection().Config(cfg).Build() + if err != nil { + return err + } + defer conn.Close() + response, _, err := gatewayaccess.Request(conn, http.MethodPost, gatewayaccess.CollectionPath(args.gatewayID), nil, bytes.NewReader(body), http.StatusCreated) + if err != nil { + return fmt.Errorf("can't grant gateway access: %w", err) + } + return gatewayaccess.WriteJSON(cmd.OutOrStdout(), response) +} diff --git a/components/cli/cmd/hsctl/delete/cmd.go b/components/cli/cmd/hsctl/delete/cmd.go index eb14e2d52..adb4b99de 100644 --- a/components/cli/cmd/hsctl/delete/cmd.go +++ b/components/cli/cmd/hsctl/delete/cmd.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/delete/gateway" + "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/delete/gatewayAccess" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/delete/gatewayNetwork" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/delete/gatewayRelease" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/delete/managedCluster" @@ -19,6 +20,7 @@ var Cmd = &cobra.Command{ func init() { Cmd.AddCommand(gateway.Cmd) + Cmd.AddCommand(gatewayAccess.Cmd) Cmd.AddCommand(gatewayNetwork.Cmd) Cmd.AddCommand(gatewayRelease.Cmd) Cmd.AddCommand(managedCluster.Cmd) diff --git a/components/cli/cmd/hsctl/delete/gatewayAccess/cmd.go b/components/cli/cmd/hsctl/delete/gatewayAccess/cmd.go new file mode 100644 index 000000000..7ca2a84ba --- /dev/null +++ b/components/cli/cmd/hsctl/delete/gatewayAccess/cmd.go @@ -0,0 +1,65 @@ +package gatewayAccess + +import ( + "fmt" + "net/http" + + "github.com/spf13/cobra" + + "github.com/openshift-online/hypershell/components/cli/pkg/config" + "github.com/openshift-online/hypershell/components/cli/pkg/connection" + "github.com/openshift-online/hypershell/components/cli/pkg/gatewayaccess" +) + +var args struct { + gatewayID string + yes bool + output string +} + +var Cmd = &cobra.Command{ + Use: "gatewayAccess USER_ID [flags]", + Aliases: []string{"gateway-access"}, + Short: "Revoke a user's access to a gateway", + Long: "Revoke all of a user's access to a gateway. Only owners may revoke an owner, and the last owner cannot be removed.", + Args: cobra.ExactArgs(1), + RunE: run, +} + +func init() { + flags := Cmd.Flags() + flags.StringVar(&args.gatewayID, "gateway-id", "", "Gateway ID.") + flags.BoolVar(&args.yes, "yes", false, "Skip confirmation.") + flags.StringVarP(&args.output, "output", "o", "json", "Structured output format (json).") +} + +func run(cmd *cobra.Command, argv []string) error { + if args.gatewayID == "" { + return fmt.Errorf("--gateway-id is required") + } + if err := gatewayaccess.ValidateOutput(args.output); err != nil { + return err + } + if !args.yes { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Revoke access for user %s on gateway %s? [y/N]: ", argv[0], args.gatewayID) + var response string + _, _ = fmt.Fscanln(cmd.InOrStdin(), &response) + if response != "y" && response != "Y" && response != "yes" { + return nil + } + } + cfg, err := config.Load() + if err != nil { + return err + } + conn, err := connection.NewConnection().Config(cfg).Build() + if err != nil { + return err + } + defer conn.Close() + _, _, err = gatewayaccess.Request(conn, http.MethodDelete, gatewayaccess.ItemPath(args.gatewayID, argv[0]), nil, nil, http.StatusNoContent) + if err != nil { + return fmt.Errorf("can't revoke gateway access: %w", err) + } + return gatewayaccess.WriteJSON(cmd.OutOrStdout(), gatewayaccess.EmptyJSON("revoked", argv[0])) +} diff --git a/components/cli/cmd/hsctl/list/cmd.go b/components/cli/cmd/hsctl/list/cmd.go index 26fd8de76..6b7a8df74 100644 --- a/components/cli/cmd/hsctl/list/cmd.go +++ b/components/cli/cmd/hsctl/list/cmd.go @@ -3,6 +3,7 @@ package list import ( "github.com/spf13/cobra" + "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/list/gatewayAccess" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/list/gatewayNetworks" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/list/gatewayReleases" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/list/gateways" @@ -21,6 +22,7 @@ var Cmd = &cobra.Command{ func init() { Cmd.AddCommand(gateways.Cmd) + Cmd.AddCommand(gatewayAccess.Cmd) Cmd.AddCommand(gatewayNetworks.Cmd) Cmd.AddCommand(gatewayReleases.Cmd) Cmd.AddCommand(managedClusters.Cmd) diff --git a/components/cli/cmd/hsctl/list/gatewayAccess/cmd.go b/components/cli/cmd/hsctl/list/gatewayAccess/cmd.go new file mode 100644 index 000000000..4e7532561 --- /dev/null +++ b/components/cli/cmd/hsctl/list/gatewayAccess/cmd.go @@ -0,0 +1,80 @@ +package gatewayAccess + +import ( + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/spf13/cobra" + + "github.com/openshift-online/hypershell/components/cli/pkg/config" + "github.com/openshift-online/hypershell/components/cli/pkg/connection" + "github.com/openshift-online/hypershell/components/cli/pkg/gatewayaccess" +) + +var args struct { + gatewayID string + role string + search string + page int + size int + sort string + order string + output string +} + +var Cmd = &cobra.Command{ + Use: "gatewayAccess [flags]", + Aliases: []string{"gateway-access"}, + Short: "List access grants on a gateway", + Args: cobra.NoArgs, + RunE: run, +} + +func init() { + flags := Cmd.Flags() + flags.StringVar(&args.gatewayID, "gateway-id", "", "Gateway ID.") + flags.StringVar(&args.role, "role", "", "Role filter: owner, admin, or user.") + flags.StringVar(&args.search, "search", "", "Case-insensitive literal search over username and name.") + flags.IntVar(&args.page, "page", 1, "Page number.") + flags.IntVar(&args.size, "size", 20, "Page size (1-100).") + flags.StringVar(&args.sort, "sort", "granted_at", "Sort field.") + flags.StringVar(&args.order, "order", "desc", "Sort order: asc or desc.") + flags.StringVarP(&args.output, "output", "o", "json", "Structured output format (json).") +} + +func run(cmd *cobra.Command, _ []string) error { + if args.gatewayID == "" { + return fmt.Errorf("--gateway-id is required") + } + if args.role != "" { + if err := gatewayaccess.ValidateRole(args.role); err != nil { + return err + } + } + if err := gatewayaccess.ValidateOutput(args.output); err != nil { + return err + } + query := url.Values{"page": {strconv.Itoa(args.page)}, "size": {strconv.Itoa(args.size)}, "sort": {args.sort}, "order": {args.order}} + if args.role != "" { + query.Set("role", args.role) + } + if args.search != "" { + query.Set("search", args.search) + } + cfg, err := config.Load() + if err != nil { + return err + } + conn, err := connection.NewConnection().Config(cfg).Build() + if err != nil { + return err + } + defer conn.Close() + response, _, err := gatewayaccess.Request(conn, http.MethodGet, gatewayaccess.CollectionPath(args.gatewayID), query, nil, http.StatusOK) + if err != nil { + return fmt.Errorf("can't list gateway access: %w", err) + } + return gatewayaccess.WriteJSON(cmd.OutOrStdout(), response) +} diff --git a/components/cli/cmd/hsctl/main.go b/components/cli/cmd/hsctl/main.go index 41f5803b1..b322ef0c8 100644 --- a/components/cli/cmd/hsctl/main.go +++ b/components/cli/cmd/hsctl/main.go @@ -17,6 +17,7 @@ import ( "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/logout" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/revoke" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/ui" + "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/update" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/version" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/whoami" ) @@ -41,6 +42,7 @@ func init() { root.AddCommand(logout.Cmd) root.AddCommand(revoke.Cmd) root.AddCommand(ui.Cmd) + root.AddCommand(update.Cmd) root.AddCommand(version.Cmd) root.AddCommand(whoami.Cmd) } diff --git a/components/cli/cmd/hsctl/update/cmd.go b/components/cli/cmd/hsctl/update/cmd.go new file mode 100644 index 000000000..72511cc66 --- /dev/null +++ b/components/cli/cmd/hsctl/update/cmd.go @@ -0,0 +1,17 @@ +package update + +import ( + "github.com/spf13/cobra" + + "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/update/gatewayAccess" +) + +var Cmd = &cobra.Command{ + Use: "update RESOURCE", + Short: "Update a resource", + Long: "Update a resource", +} + +func init() { + Cmd.AddCommand(gatewayAccess.Cmd) +} diff --git a/components/cli/cmd/hsctl/update/gatewayAccess/cmd.go b/components/cli/cmd/hsctl/update/gatewayAccess/cmd.go new file mode 100644 index 000000000..1ca033480 --- /dev/null +++ b/components/cli/cmd/hsctl/update/gatewayAccess/cmd.go @@ -0,0 +1,66 @@ +package gatewayAccess + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "github.com/spf13/cobra" + + "github.com/openshift-online/hypershell/components/cli/pkg/config" + "github.com/openshift-online/hypershell/components/cli/pkg/connection" + "github.com/openshift-online/hypershell/components/cli/pkg/gatewayaccess" +) + +var args struct { + gatewayID string + role string + output string +} + +var Cmd = &cobra.Command{ + Use: "gatewayAccess USER_ID [flags]", + Aliases: []string{"gateway-access"}, + Short: "Change a user's access role on a gateway", + Long: "Change a user's access to owner, admin, or user. Only owners may assign, change, or revoke the owner role.", + Args: cobra.ExactArgs(1), + RunE: run, +} + +func init() { + flags := Cmd.Flags() + flags.StringVar(&args.gatewayID, "gateway-id", "", "Gateway ID.") + flags.StringVar(&args.role, "role", "", "New access role: owner, admin, or user.") + flags.StringVarP(&args.output, "output", "o", "json", "Structured output format (json).") +} + +func run(cmd *cobra.Command, argv []string) error { + if args.gatewayID == "" { + return fmt.Errorf("--gateway-id is required") + } + if err := gatewayaccess.ValidateRole(args.role); err != nil { + return err + } + if err := gatewayaccess.ValidateOutput(args.output); err != nil { + return err + } + body, err := json.Marshal(map[string]any{"role": args.role}) + if err != nil { + return fmt.Errorf("can't encode request: %w", err) + } + cfg, err := config.Load() + if err != nil { + return err + } + conn, err := connection.NewConnection().Config(cfg).Build() + if err != nil { + return err + } + defer conn.Close() + response, _, err := gatewayaccess.Request(conn, http.MethodPatch, gatewayaccess.ItemPath(args.gatewayID, argv[0]), nil, bytes.NewReader(body), http.StatusOK) + if err != nil { + return fmt.Errorf("can't change gateway access: %w", err) + } + return gatewayaccess.WriteJSON(cmd.OutOrStdout(), response) +} diff --git a/components/cli/pkg/gatewayaccess/gatewayaccess.go b/components/cli/pkg/gatewayaccess/gatewayaccess.go new file mode 100644 index 000000000..0880b1b88 --- /dev/null +++ b/components/cli/pkg/gatewayaccess/gatewayaccess.go @@ -0,0 +1,91 @@ +package gatewayaccess + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/url" + + "github.com/openshift-online/hypershell/components/cli/pkg/connection" +) + +// CollectionPath is the gateway access facade collection. +func CollectionPath(gatewayID string) string { + return "/api/hypershell/v1/gateways/" + url.PathEscape(gatewayID) + "/access" +} + +// ItemPath is a single user's access on a gateway (keyed by user id). +func ItemPath(gatewayID, userID string) string { + return CollectionPath(gatewayID) + "/" + url.PathEscape(userID) +} + +// Request performs the HTTP call and returns the body. Any status not listed in +// accepted is turned into an error carrying the API's {code,reason} so a 403 +// (unauthorized management) or 409 (last-owner protection) surfaces as a +// non-zero exit with a clear message rather than a silent success. +func Request(conn *connection.Connection, method, path string, query url.Values, body io.Reader, accepted ...int) ([]byte, int, error) { + response, err := conn.Do(method, path, query, body) + if err != nil { + return nil, 0, fmt.Errorf("request failed: %w", err) + } + defer response.Body.Close() + responseBody, err := io.ReadAll(io.LimitReader(response.Body, 10<<20)) + if err != nil { + return nil, response.StatusCode, fmt.Errorf("can't read API response: %w", err) + } + for _, status := range accepted { + if response.StatusCode == status { + return responseBody, response.StatusCode, nil + } + } + var problem struct { + Code string `json:"code"` + Reason string `json:"reason"` + } + _ = json.Unmarshal(responseBody, &problem) + if problem.Reason != "" { + return nil, response.StatusCode, fmt.Errorf("API returned %d (%s): %s", response.StatusCode, problem.Code, problem.Reason) + } + return nil, response.StatusCode, fmt.Errorf("API returned %d", response.StatusCode) +} + +// ValidateRole enforces the console tier vocabulary. +func ValidateRole(value string) error { + if value != "owner" && value != "admin" && value != "user" { + return fmt.Errorf("--role must be one of owner, admin, or user") + } + return nil +} + +// ValidateOutput mirrors the service-account CLI: only json output is supported. +func ValidateOutput(value string) error { + if value != "json" { + return fmt.Errorf("--output must be json") + } + return nil +} + +// WriteJSON pretty-prints the API response (or a small synthetic object) to the +// writer. +func WriteJSON(writer io.Writer, body []byte) error { + var value any + if err := json.Unmarshal(body, &value); err != nil { + return fmt.Errorf("API returned invalid JSON: %w", err) + } + var rendered bytes.Buffer + encoder := json.NewEncoder(&rendered) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + if err := encoder.Encode(value); err != nil { + return fmt.Errorf("can't encode output: %w", err) + } + _, err := writer.Write(rendered.Bytes()) + return err +} + +// EmptyJSON is returned for 204 responses that carry no body. +func EmptyJSON(status, userID string) []byte { + body, _ := json.Marshal(map[string]string{"user_id": userID, "status": status}) + return body +} diff --git a/components/cli/pkg/gatewayaccess/gatewayaccess_test.go b/components/cli/pkg/gatewayaccess/gatewayaccess_test.go new file mode 100644 index 000000000..0394f7ecf --- /dev/null +++ b/components/cli/pkg/gatewayaccess/gatewayaccess_test.go @@ -0,0 +1,104 @@ +package gatewayaccess + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/openshift-online/hypershell/components/cli/pkg/config" + "github.com/openshift-online/hypershell/components/cli/pkg/connection" +) + +func TestValidateRole(t *testing.T) { + for _, ok := range []string{"owner", "admin", "user"} { + if err := ValidateRole(ok); err != nil { + t.Errorf("ValidateRole(%q) = %v, want nil", ok, err) + } + } + for _, bad := range []string{"", "openshell-admin", "viewer", "Owner"} { + if err := ValidateRole(bad); err == nil { + t.Errorf("ValidateRole(%q) = nil, want error", bad) + } + } +} + +func newConn(t *testing.T, serverURL string) *connection.Connection { + t.Helper() + conn, err := connection.NewConnection().Config(&config.Config{URL: serverURL, AccessToken: "management-token"}).Build() + if err != nil { + t.Fatal(err) + } + return conn +} + +// A 409 (last-owner protection) must surface as a non-zero-exit error carrying +// the API's reason so the operator sees actionable guidance (GAM-13). +func TestRequest_LastOwnerConflictSurfacesReason(t *testing.T) { + const reason = "this gateway must keep at least one owner" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"code":"conflict","reason":"` + reason + `"}`)) + })) + defer server.Close() + conn := newConn(t, server.URL) + defer conn.Close() + + _, status, err := Request(conn, http.MethodDelete, "/access/u1", nil, nil, http.StatusNoContent) + if err == nil { + t.Fatal("expected a 409 to be an error") + } + if status != http.StatusConflict { + t.Fatalf("status = %d, want 409", status) + } + if !strings.Contains(err.Error(), reason) { + t.Fatalf("error %q does not carry the last-owner reason", err.Error()) + } +} + +// A 403 (unauthorized management) must also surface as an error, not a silent +// success. +func TestRequest_ForbiddenSurfaces(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"code":"forbidden","reason":"only owners can assign the owner role"}`)) + })) + defer server.Close() + conn := newConn(t, server.URL) + defer conn.Close() + + _, _, err := Request(conn, http.MethodPost, "/access", nil, nil, http.StatusCreated) + if err == nil { + t.Fatal("expected a 403 to be an error") + } + if !strings.Contains(err.Error(), "403") { + t.Fatalf("error %q should mention the status", err.Error()) + } +} + +func TestRequest_AcceptedReturnsBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"role":"admin"}`)) + })) + defer server.Close() + conn := newConn(t, server.URL) + defer conn.Close() + + body, status, err := Request(conn, http.MethodGet, "/access", nil, nil, http.StatusOK) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status != http.StatusOK || !strings.Contains(string(body), "admin") { + t.Fatalf("unexpected response: status=%d body=%s", status, body) + } +} + +func TestPaths(t *testing.T) { + if got := CollectionPath("gw 1"); got != "/api/hypershell/v1/gateways/gw%201/access" { + t.Errorf("CollectionPath escaping = %q", got) + } + if got := ItemPath("gw1", "u/1"); got != "/api/hypershell/v1/gateways/gw1/access/u%2F1" { + t.Errorf("ItemPath escaping = %q", got) + } +} diff --git a/components/control-plane/cmd/hypershell-controller/main.go b/components/control-plane/cmd/hypershell-controller/main.go index b43cd208f..e96d6e878 100644 --- a/components/control-plane/cmd/hypershell-controller/main.go +++ b/components/control-plane/cmd/hypershell-controller/main.go @@ -18,8 +18,10 @@ import ( "k8s.io/client-go/rest" pb "github.com/openshift-online/hypershell/components/api-server/pkg/api/grpc/hypershell/v1" + provisionerpb "github.com/openshift-online/hypershell/components/api-server/pkg/api/grpc/hypershell/provisioner/v1" "github.com/openshift-online/hypershell/components/control-plane/internal/auth" "github.com/openshift-online/hypershell/components/control-plane/internal/config" + "github.com/openshift-online/hypershell/components/control-plane/internal/directory" "github.com/openshift-online/hypershell/components/control-plane/internal/exposure" "github.com/openshift-online/hypershell/components/control-plane/internal/gateway" "github.com/openshift-online/hypershell/components/control-plane/internal/grpctransport" @@ -278,8 +280,18 @@ func main() { transportConfig := serviceaccountprovisioner.TransportConfig{ Address: cfg.ServiceAccountProvisionerAddress, } + // The Keycloak realm directory projection (GAM-09) rides the same private + // in-cluster channel. It needs Keycloak, so it is served only when a + // Keycloak client is configured; the API server dials this one address for + // both the provisioner and the directory. + var directorySvc provisionerpb.DirectoryServiceServer + if kcClient != nil { + projection := directory.NewProjection(kcClient, cfg.DirectoryRefreshInterval) + directorySvc = directory.NewServer(projection) + supervise("keycloak directory projection", projection.Run) + } supervise("service-account provisioner", func(ctx context.Context) error { - return serviceaccountprovisioner.ListenAndServe(ctx, transportConfig, provisionerServer) + return serviceaccountprovisioner.ListenAndServe(ctx, transportConfig, provisionerServer, directorySvc) }) log.Printf("INFO service-account provisioner launched on %s (in-cluster, NetworkPolicy-restricted)", cfg.ServiceAccountProvisionerAddress) } else { diff --git a/components/control-plane/internal/config/config.go b/components/control-plane/internal/config/config.go index b0a772f22..99ff0652c 100644 --- a/components/control-plane/internal/config/config.go +++ b/components/control-plane/internal/config/config.go @@ -64,6 +64,10 @@ type Config struct { // it is reaped. NamespaceGCGracePeriod time.Duration + // DirectoryRefreshInterval is the cadence of the Keycloak realm directory + // projection refresh (GAM-09). Bounded staleness equals this interval. + DirectoryRefreshInterval time.Duration + // GatewayReconcileWorkers bounds how many distinct gateways the control // plane provisions concurrently: the size of the gateway reconcile queue // worker pool. Work for a single gateway is always serialized; this only @@ -112,6 +116,8 @@ func Load() (*Config, error) { NamespaceGCInterval: getEnvDuration("GATEWAY_NAMESPACE_GC_INTERVAL", 5*time.Minute), NamespaceGCGracePeriod: getEnvDuration("GATEWAY_NAMESPACE_GC_GRACE_PERIOD", 10*time.Minute), + DirectoryRefreshInterval: getEnvDuration("GATEWAY_DIRECTORY_REFRESH_INTERVAL", 5*time.Minute), + GatewayReconcileWorkers: getEnvInt("GATEWAY_RECONCILE_WORKERS", DefaultGatewayReconcileWorkers, 1), HelmChartPath: getEnv("HELM_CHART_PATH", "/charts/openshell.tgz"), diff --git a/components/control-plane/internal/directory/directory.go b/components/control-plane/internal/directory/directory.go new file mode 100644 index 000000000..2483d5e5b --- /dev/null +++ b/components/control-plane/internal/directory/directory.go @@ -0,0 +1,146 @@ +// Package directory maintains an in-memory projection of the Keycloak realm +// users and serves it to the API server (GAM-09). The API server never reads the +// Keycloak admin Secret; it reads this projection over the private in-cluster +// provisioner channel. +package directory + +import ( + "context" + "log" + "strings" + "sync" + "time" + + pb "github.com/openshift-online/hypershell/components/api-server/pkg/api/grpc/hypershell/provisioner/v1" + "github.com/openshift-online/hypershell/components/control-plane/internal/keycloak" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + defaultRefreshInterval = 5 * time.Minute + defaultSearchLimit = 50 +) + +// lister is the slice of the Keycloak client the projection depends on. +type lister interface { + ListRealmUsers(ctx context.Context) ([]keycloak.RealmUser, error) +} + +// Projection is a periodically-refreshed snapshot of the realm directory. +type Projection struct { + lister lister + interval time.Duration + + mu sync.RWMutex + users []keycloak.RealmUser +} + +func NewProjection(l lister, interval time.Duration) *Projection { + if interval <= 0 { + interval = defaultRefreshInterval + } + return &Projection{lister: l, interval: interval} +} + +// Run refreshes immediately, then on the configured interval until ctx is done. +func (p *Projection) Run(ctx context.Context) error { + log.Printf("INFO keycloak directory projection started (interval=%s)", p.interval) + p.refreshOnce(ctx) + ticker := time.NewTicker(p.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + p.refreshOnce(ctx) + } + } +} + +// refreshOnce replaces the snapshot. On error it keeps the previous snapshot so +// search stays available across transient Keycloak unavailability. +func (p *Projection) refreshOnce(ctx context.Context) { + users, err := p.lister.ListRealmUsers(ctx) + if err != nil { + log.Printf("WARN keycloak directory refresh failed, keeping previous snapshot: %v", err) + return + } + p.mu.Lock() + p.users = users + p.mu.Unlock() + log.Printf("INFO keycloak directory refreshed (%d users)", len(users)) +} + +func (p *Projection) snapshot() []keycloak.RealmUser { + p.mu.RLock() + defer p.mu.RUnlock() + return p.users +} + +// Search returns realm users whose username or name contains query (case- +// insensitive substring), capped at limit. +// ponytail: O(n) scan over the snapshot; fine for realm sizes served from memory. +func (p *Projection) Search(query string, limit int) []keycloak.RealmUser { + if limit <= 0 || limit > defaultSearchLimit { + limit = defaultSearchLimit + } + q := strings.ToLower(strings.TrimSpace(query)) + out := make([]keycloak.RealmUser, 0, limit) + for _, u := range p.snapshot() { + if q == "" || strings.Contains(strings.ToLower(u.Username), q) || strings.Contains(strings.ToLower(u.Name), q) { + out = append(out, u) + if len(out) >= limit { + break + } + } + } + return out +} + +// Resolve returns the realm user for an exact (case-insensitive) username. +func (p *Projection) Resolve(username string) (keycloak.RealmUser, bool) { + want := strings.ToLower(strings.TrimSpace(username)) + if want == "" { + return keycloak.RealmUser{}, false + } + for _, u := range p.snapshot() { + if strings.ToLower(u.Username) == want { + return u, true + } + } + return keycloak.RealmUser{}, false +} + +// Server adapts a Projection to the gRPC DirectoryService. +type Server struct { + pb.UnimplementedDirectoryServiceServer + projection *Projection +} + +func NewServer(p *Projection) *Server { return &Server{projection: p} } + +func (s *Server) SearchDirectory(_ context.Context, req *pb.SearchDirectoryRequest) (*pb.SearchDirectoryResponse, error) { + users := s.projection.Search(req.GetQuery(), int(req.GetLimit())) + resp := &pb.SearchDirectoryResponse{Users: make([]*pb.DirectoryUser, 0, len(users))} + for _, u := range users { + resp.Users = append(resp.Users, toProto(u)) + } + return resp, nil +} + +func (s *Server) ResolveUser(_ context.Context, req *pb.ResolveUserRequest) (*pb.ResolveUserResponse, error) { + if req.GetUsername() == "" { + return nil, status.Error(codes.InvalidArgument, "username is required") + } + u, found := s.projection.Resolve(req.GetUsername()) + if !found { + return &pb.ResolveUserResponse{Found: false}, nil + } + return &pb.ResolveUserResponse{Found: true, User: toProto(u)}, nil +} + +func toProto(u keycloak.RealmUser) *pb.DirectoryUser { + return &pb.DirectoryUser{Username: u.Username, Name: u.Name, Email: u.Email, Subject: u.Subject} +} diff --git a/components/control-plane/internal/directory/directory_test.go b/components/control-plane/internal/directory/directory_test.go new file mode 100644 index 000000000..0ec95fa47 --- /dev/null +++ b/components/control-plane/internal/directory/directory_test.go @@ -0,0 +1,66 @@ +package directory + +import ( + "context" + "errors" + "testing" + + "github.com/openshift-online/hypershell/components/control-plane/internal/keycloak" +) + +type fakeLister struct { + users []keycloak.RealmUser + err error +} + +func (f fakeLister) ListRealmUsers(context.Context) ([]keycloak.RealmUser, error) { + return f.users, f.err +} + +func seeded() *Projection { + p := NewProjection(fakeLister{users: []keycloak.RealmUser{ + {Username: "dana", Name: "Dana Scully", Email: "dana@x"}, + {Username: "dale", Name: "Dale Cooper"}, + {Username: "mulder", Name: "Fox Mulder"}, + }}, 0) + p.refreshOnce(context.Background()) + return p +} + +func TestSearch_SubstringOverUsernameAndName(t *testing.T) { + p := seeded() + got := p.Search("da", 50) + if len(got) != 2 { + t.Fatalf("Search(da) = %d users, want 2 (dana, dale)", len(got)) + } + // Name match: "cooper" only matches dale's name. + if got := p.Search("cooper", 50); len(got) != 1 || got[0].Username != "dale" { + t.Fatalf("Search(cooper) = %v, want [dale]", got) + } +} + +func TestSearch_LimitCaps(t *testing.T) { + if got := seeded().Search("", 1); len(got) != 1 { + t.Fatalf("Search('' , limit 1) = %d, want 1", len(got)) + } +} + +func TestResolve_ExactCaseInsensitive(t *testing.T) { + p := seeded() + u, ok := p.Resolve("DANA") + if !ok || u.Email != "dana@x" { + t.Fatalf("Resolve(DANA) = %v, %v; want dana found", u, ok) + } + if _, ok := p.Resolve("ghost"); ok { + t.Fatal("Resolve(ghost) found a user that is not in the realm") + } +} + +func TestRefresh_KeepsPreviousSnapshotOnError(t *testing.T) { + p := seeded() + p.lister = fakeLister{err: errors.New("keycloak down")} + p.refreshOnce(context.Background()) + if _, ok := p.Resolve("dana"); !ok { + t.Fatal("a failed refresh must keep the previous snapshot") + } +} diff --git a/components/control-plane/internal/keycloak/client.go b/components/control-plane/internal/keycloak/client.go index 49b846c8a..ee301a03c 100644 --- a/components/control-plane/internal/keycloak/client.go +++ b/components/control-plane/internal/keycloak/client.go @@ -76,8 +76,20 @@ type keycloakRole struct { } type keycloakUser struct { - ID string `json:"id"` - Username string `json:"username"` + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email,omitempty"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` +} + +// RealmUser is a projection of a Keycloak realm user for the gateway access +// directory (GAM-09). Subject is the Keycloak user id (the OIDC sub). +type RealmUser struct { + Username string + Name string + Email string + Subject string } // ClientNotFoundError is returned when a Keycloak client lookup finds no @@ -208,6 +220,45 @@ func (c *Client) DeleteGatewayServiceAccountClients(ctx context.Context, gateway return nil } +// ListRealmUsers returns every user in the configured realm as a directory +// projection (GAM-09), paging the Keycloak Admin users endpoint. The name is the +// first+last name when present, else the username. +func (c *Client) ListRealmUsers(ctx context.Context) ([]RealmUser, error) { + const pageSize = 100 + out := make([]RealmUser, 0) + for first := 0; ; first += pageSize { + path := fmt.Sprintf("/admin/realms/%s/users?first=%d&max=%d", c.realm, first, pageSize) + response, err := c.doRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("list realm users: %w", err) + } + var page []keycloakUser + if err := json.Unmarshal(response, &page); err != nil { + return nil, fmt.Errorf("parse realm user list: %w", err) + } + for _, u := range page { + out = append(out, RealmUser{ + Username: u.Username, + Name: displayName(u), + Email: u.Email, + Subject: u.ID, + }) + } + if len(page) < pageSize { + break + } + } + return out, nil +} + +func displayName(u keycloakUser) string { + name := strings.TrimSpace(u.FirstName + " " + u.LastName) + if name == "" { + return u.Username + } + return name +} + // AssignClientRole assigns a Keycloak client role to a user on a gateway. func (c *Client) AssignClientRole(ctx context.Context, gatewayName, username, roleName string) error { log.Printf("INFO keycloak: assigning role %s to user %s on client %s", roleName, username, gatewayName) diff --git a/components/control-plane/internal/keycloak/client_test.go b/components/control-plane/internal/keycloak/client_test.go index d4ba91ac1..8f29b629a 100644 --- a/components/control-plane/internal/keycloak/client_test.go +++ b/components/control-plane/internal/keycloak/client_test.go @@ -874,3 +874,39 @@ func equalStrings(got, want []string) bool { } return true } + +func TestListRealmUsers_PagesAndMapsNames(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc(fmt.Sprintf("/realms/%s/protocol/openid-connect/token", testRealm), func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"access_token": "fake-token", "expires_in": 300}) + }) + mux.HandleFunc(fmt.Sprintf("/admin/realms/%s/users", testRealm), func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // First page returns users; any later page is empty, ending the loop. + if r.URL.Query().Get("first") != "0" { + _, _ = w.Write([]byte(`[]`)) + return + } + _ = json.NewEncoder(w).Encode([]keycloakUser{ + {ID: "sub-dana", Username: "dana", Email: "dana@x", FirstName: "Dana", LastName: "Scully"}, + {ID: "sub-dale", Username: "dale"}, + }) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + users, err := NewClient(srv.URL, testRealm, testAdminClientID, testAdminSecret).ListRealmUsers(t.Context()) + if err != nil { + t.Fatalf("ListRealmUsers: %v", err) + } + if len(users) != 2 { + t.Fatalf("got %d users, want 2", len(users)) + } + if users[0].Name != "Dana Scully" || users[0].Subject != "sub-dana" || users[0].Email != "dana@x" { + t.Errorf("user[0] = %+v, want name=Dana Scully subject=sub-dana", users[0]) + } + if users[1].Name != "dale" { // no first/last -> fall back to username + t.Errorf("user[1].Name = %q, want fallback to username dale", users[1].Name) + } +} diff --git a/components/control-plane/internal/reconciler/role_binding_reconciler.go b/components/control-plane/internal/reconciler/role_binding_reconciler.go index 306a8affa..251cd1659 100644 --- a/components/control-plane/internal/reconciler/role_binding_reconciler.go +++ b/components/control-plane/internal/reconciler/role_binding_reconciler.go @@ -20,9 +20,16 @@ import ( // gateway info but is refused "list workspaces" with "role 'openshell-user' required". var keycloakRoleMap = map[string][]string{ "gateway:owner": {"openshell-admin", "openshell-user"}, + "gateway:admin": {"openshell-admin", "openshell-user"}, "gateway:viewer": {"openshell-user"}, } +// allGatewayKcRoles is the full vocabulary of per-gateway Keycloak client roles +// the OIDC Role Bridge manages. Union reconcile assigns the roles a user should +// hold and removes the rest from this set, so it is listed explicitly (and +// deterministically) rather than derived from map iteration order. +var allGatewayKcRoles = []string{"openshell-admin", "openshell-user"} + type RoleBindingReconciler struct { mu sync.Mutex active map[string]struct{} @@ -109,52 +116,57 @@ func (r *RoleBindingReconciler) Handle(ctx context.Context, event watcher.Event[ return nil } - switch event.Type { - case watcher.EventCreated, watcher.EventUpdated: + // On every event, reconcile the user's client roles on this gateway to the + // exact union implied by their surviving bindings (GAM-02): assign any + // missing role and remove any role no longer backed by a binding. This makes + // promotions, demotions (including an in-place role_id UPDATE from + // gateway:admin to gateway:viewer that must strip openshell-admin while + // keeping openshell-user), and overlapping grants all converge correctly and + // idempotently. + var excludeID string + if event.Type == watcher.EventDeleted { + // The deleted binding may still be visible if the event races ahead of + // its soft-delete; exclude it by ID. + excludeID = rb.GetMetadata().GetId() + } + desired, err := r.effectiveKcRoles(ctx, rb, excludeID) + if err != nil { + reconcileErr = fmt.Errorf("compute effective keycloak roles for role binding %s: %w", event.ResourceID, err) + return reconcileErr + } + if event.Type != watcher.EventDeleted { + // Include the event binding's own roles even if the list has not yet + // caught up with this create/update. for _, kcRole := range kcRoles { + desired[kcRole] = true + } + } + + for _, kcRole := range allGatewayKcRoles { + if desired[kcRole] { log.Printf("INFO assigning keycloak role %s to user %s on client %s", kcRole, username, kcClientID) if err := r.keycloakClient.AssignClientRole(ctx, kcClientID, username, kcRole); err != nil { reconcileErr = fmt.Errorf("assign keycloak role %s to user %s on client %s: %w", kcRole, username, kcClientID, err) return reconcileErr } + continue } - - case watcher.EventDeleted: - // A single Keycloak role can be granted by more than one platform - // RoleBinding (both gateway:owner and gateway:viewer grant - // openshell-user). Revoking blindly would strip a role the user still - // holds via another surviving binding, so recompute the roles still - // desired for this user+gateway and only revoke what is no longer - // backed by any remaining binding. - stillDesired, err := r.stillDesiredKcRoles(ctx, rb) - if err != nil { - reconcileErr = fmt.Errorf("recompute effective keycloak roles for role binding %s: %w", event.ResourceID, err) + log.Printf("INFO removing keycloak role %s from user %s on client %s", kcRole, username, kcClientID) + if err := r.keycloakClient.RemoveClientRole(ctx, kcClientID, username, kcRole); err != nil { + reconcileErr = fmt.Errorf("remove keycloak role %s from user %s on client %s: %w", kcRole, username, kcClientID, err) return reconcileErr } - for _, kcRole := range kcRoles { - if stillDesired[kcRole] { - log.Printf("INFO keeping keycloak role %s for user %s on client %s: still granted by another role binding", kcRole, username, kcClientID) - continue - } - log.Printf("INFO removing keycloak role %s from user %s on client %s", kcRole, username, kcClientID) - if err := r.keycloakClient.RemoveClientRole(ctx, kcClientID, username, kcRole); err != nil { - reconcileErr = fmt.Errorf("remove keycloak role %s from user %s on client %s: %w", kcRole, username, kcClientID, err) - return reconcileErr - } - } } return nil } -// stillDesiredKcRoles returns the set of Keycloak client roles the user should -// still hold on the deleted binding's gateway, computed as the union of the role -// mappings across every remaining (non-deleted) RoleBinding for that user and -// gateway. The deleted binding is excluded by ID in case the delete event races -// ahead of its soft-delete becoming visible. -func (r *RoleBindingReconciler) stillDesiredKcRoles(ctx context.Context, deleted *pb.RoleBinding) (map[string]bool, error) { +// effectiveKcRoles returns the union of Keycloak client roles implied by every +// surviving RoleBinding for this user and gateway. The binding identified by +// excludeID (the one being deleted) is omitted; pass "" to include all. +func (r *RoleBindingReconciler) effectiveKcRoles(ctx context.Context, rb *pb.RoleBinding, excludeID string) (map[string]bool, error) { desired := make(map[string]bool) - if deleted.GatewayId == nil { + if rb.GatewayId == nil { return desired, nil } @@ -167,15 +179,15 @@ func (r *RoleBindingReconciler) stillDesiredKcRoles(ctx context.Context, deleted } client := pb.NewRoleBindingServiceClient(r.grpcConn) resp, err := client.ListRoleBindings(ctx, &pb.ListRoleBindingsRequest{ - UserId: deleted.UserId, - GatewayId: deleted.GatewayId, + UserId: rb.UserId, + GatewayId: rb.GatewayId, ClusterId: clusterFilter, }) if err != nil { - return nil, fmt.Errorf("list role bindings for user %s on gateway %s: %w", deleted.GetUserId(), *deleted.GatewayId, err) + return nil, fmt.Errorf("list role bindings for user %s on gateway %s: %w", rb.GetUserId(), *rb.GatewayId, err) } - return unionKcRoles(resp.GetItems(), deleted.GetMetadata().GetId()), nil + return unionKcRoles(resp.GetItems(), excludeID), nil } // unionKcRoles collects the Keycloak client roles granted by every binding in diff --git a/components/control-plane/internal/reconciler/role_binding_reconciler_test.go b/components/control-plane/internal/reconciler/role_binding_reconciler_test.go index c5a1bcd95..24fc54d7c 100644 --- a/components/control-plane/internal/reconciler/role_binding_reconciler_test.go +++ b/components/control-plane/internal/reconciler/role_binding_reconciler_test.go @@ -76,6 +76,7 @@ func TestKeycloakRoleMap_OwnerGetsAdminAndUser(t *testing.T) { want []string }{ {"gateway:owner", []string{"openshell-admin", "openshell-user"}}, + {"gateway:admin", []string{"openshell-admin", "openshell-user"}}, {"gateway:viewer", []string{"openshell-user"}}, } for _, tc := range cases { @@ -94,6 +95,37 @@ func TestKeycloakRoleMap_OwnerGetsAdminAndUser(t *testing.T) { } } +// After an in-place demotion from gateway:admin to gateway:viewer (a single +// UPDATED event, one surviving binding), union reconcile must assign +// openshell-user and remove openshell-admin (GAM-02). +func TestUnionReconcile_DemotionToViewerStripsAdminKeepsUser(t *testing.T) { + surviving := []*pb.RoleBinding{roleBinding("rb-1", "gateway:viewer")} + desired := unionKcRoles(surviving, "") + + var assigned, removed []string + for _, kc := range allGatewayKcRoles { + if desired[kc] { + assigned = append(assigned, kc) + } else { + removed = append(removed, kc) + } + } + if len(assigned) != 1 || assigned[0] != "openshell-user" { + t.Errorf("assigned = %v, want [openshell-user]", assigned) + } + if len(removed) != 1 || removed[0] != "openshell-admin" { + t.Errorf("removed = %v, want [openshell-admin]", removed) + } +} + +// A granted admin (gateway:admin) must receive both client roles, like an owner. +func TestUnionReconcile_AdminGetsBothRoles(t *testing.T) { + desired := unionKcRoles([]*pb.RoleBinding{roleBinding("rb-1", "gateway:admin")}, "") + if !desired["openshell-admin"] || !desired["openshell-user"] { + t.Errorf("gateway:admin union = %v, want both openshell-admin and openshell-user", desired) + } +} + func TestHandle_EmptyUsernameIsError(t *testing.T) { r := NewRoleBindingReconciler(keycloak.NewClient("http://keycloak", "hypershell", "id", "secret"), nil, "cluster-1") gatewayID := "gw-1" @@ -167,7 +199,10 @@ func TestHandle_SkipsGatewayOfAnotherCluster(t *testing.T) { // Keycloak sync (which here fails because Keycloak is unroutable), proving the // cluster check is what gates the skip above. func TestHandle_OwnClusterGatewayReachesKeycloak(t *testing.T) { - conn, recorder := newRecordingGatewayConn(t) + // Union reconcile lists the user's surviving bindings before touching + // Keycloak, so the stub must serve RoleBindingService (empty is fine; the + // event binding's own roles are still included in the desired union). + conn, recorder, _ := newRecordingGatewayAndRoleBindingConn(t) recorder.setGateway(&pb.Gateway{ Metadata: &pb.ObjectReference{Id: "gw-1"}, Name: "team-gateway", diff --git a/components/control-plane/internal/serviceaccountprovisioner/transport.go b/components/control-plane/internal/serviceaccountprovisioner/transport.go index 165665525..bd6a536ee 100644 --- a/components/control-plane/internal/serviceaccountprovisioner/transport.go +++ b/components/control-plane/internal/serviceaccountprovisioner/transport.go @@ -21,7 +21,7 @@ type TransportConfig struct { // rather than adding mTLS. See // specs/platform/openshell-gateway-service-accounts.spec.md (Internal Provisioner // Network Isolation). -func ListenAndServe(ctx context.Context, config TransportConfig, handler *Server) error { +func ListenAndServe(ctx context.Context, config TransportConfig, handler *Server, directory pb.DirectoryServiceServer) error { if config.Address == "" { return fmt.Errorf("service-account provisioner bind address is required") } @@ -31,6 +31,11 @@ func ListenAndServe(ctx context.Context, config TransportConfig, handler *Server } grpcServer := grpc.NewServer() pb.RegisterOpenShellGatewayServiceAccountProvisionerServiceServer(grpcServer, handler) + // The Keycloak realm directory projection (GAM-09) rides the same private + // in-cluster channel; the API server dials this one address for both. + if directory != nil { + pb.RegisterDirectoryServiceServer(grpcServer, directory) + } healthServer := health.NewServer() healthServer.SetServingStatus("", healthpb.HealthCheckResponse_SERVING) healthpb.RegisterHealthServer(grpcServer, healthServer) diff --git a/components/control-plane/internal/serviceaccountprovisioner/transport_test.go b/components/control-plane/internal/serviceaccountprovisioner/transport_test.go index 285aa69f4..462befa3f 100644 --- a/components/control-plane/internal/serviceaccountprovisioner/transport_test.go +++ b/components/control-plane/internal/serviceaccountprovisioner/transport_test.go @@ -12,7 +12,7 @@ import ( ) func TestListenAndServeRequiresBindAddress(t *testing.T) { - if err := ListenAndServe(t.Context(), TransportConfig{}, NewServer(&fakeProvider{configured: true})); err == nil { + if err := ListenAndServe(t.Context(), TransportConfig{}, NewServer(&fakeProvider{configured: true}), nil); err == nil { t.Fatal("ListenAndServe() error = nil, want error for empty bind address") } } @@ -31,7 +31,7 @@ func TestListenAndServeServesPlaintextProvisionerCalls(t *testing.T) { served := make(chan error, 1) go func() { served <- ListenAndServe(ctx, TransportConfig{Address: address}, - NewServer(&fakeProvider{configured: true, secret: "one-time-secret"})) + NewServer(&fakeProvider{configured: true, secret: "one-time-secret"}), nil) }() connection, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials())) diff --git a/components/sdk-go/client/client.go b/components/sdk-go/client/client.go index 13bf571ec..db96fcec8 100644 --- a/components/sdk-go/client/client.go +++ b/components/sdk-go/client/client.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 package client diff --git a/components/sdk-go/client/gateway_access_api.go b/components/sdk-go/client/gateway_access_api.go new file mode 100644 index 000000000..110299f1c --- /dev/null +++ b/components/sdk-go/client/gateway_access_api.go @@ -0,0 +1,117 @@ +// Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. +// Source: components/api-server/openapi/openapi.yaml +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 + +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/openshift-online/hypershell/components/sdk-go/types" +) + +type GatewayAccessAPI struct { + client *Client +} + +func (c *Client) GatewayAccesses() *GatewayAccessAPI { + return &GatewayAccessAPI{client: c} +} + +func (a *GatewayAccessAPI) Create(ctx context.Context, gatewayID string, request *types.GatewayAccessGrantRequest) (*types.GatewayAccessGrantResponse, error) { + body, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("marshal gateway_access create request: %w", err) + } + var result types.GatewayAccessGrantResponse + if _, err := a.client.doAny(ctx, http.MethodPost, fmt.Sprintf("/gateways/%s/access", url.PathEscape(gatewayID)), body, &result, http.StatusCreated); err != nil { + return nil, err + } + return &result, nil +} + +func (a *GatewayAccessAPI) List(ctx context.Context, gatewayID string, opts *types.GatewayAccessListOptions) (*types.GatewayAccessList, error) { + path := fmt.Sprintf("/gateways/%s/access", url.PathEscape(gatewayID)) + if opts != nil { + params := url.Values{} + if opts.Page > 0 { + params.Set("page", fmt.Sprintf("%d", opts.Page)) + } + if opts.Size > 0 { + params.Set("size", fmt.Sprintf("%d", opts.Size)) + } + if opts.Role != "" { + params.Set("role", string(opts.Role)) + } + if opts.Search != "" { + params.Set("search", string(opts.Search)) + } + if opts.Sort != "" { + params.Set("sort", string(opts.Sort)) + } + if opts.Order != "" { + params.Set("order", string(opts.Order)) + } + if len(params) > 0 { + path += "?" + params.Encode() + } + } + var result types.GatewayAccessList + if _, err := a.client.doAny(ctx, http.MethodGet, path, nil, &result, http.StatusOK); err != nil { + return nil, err + } + return &result, nil +} + +func (a *GatewayAccessAPI) Delete(ctx context.Context, gatewayID string, userID string) (*types.GatewayAccessListItem, error) { + var result types.GatewayAccessListItem + status, err := a.client.doAny(ctx, http.MethodDelete, fmt.Sprintf("/gateways/%s/access/%s", url.PathEscape(gatewayID), url.PathEscape(userID)), nil, &result, http.StatusNoContent, http.StatusAccepted) + if err != nil { + return nil, err + } + if status == http.StatusNoContent { + return nil, nil + } + return &result, nil +} + +func (a *GatewayAccessAPI) Update(ctx context.Context, gatewayID string, userID string, request *types.GatewayAccessChangeRoleRequest) (*types.GatewayAccessGrantResponse, error) { + body, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("marshal gateway_access update request: %w", err) + } + var result types.GatewayAccessGrantResponse + if _, err := a.client.doAny(ctx, http.MethodPatch, fmt.Sprintf("/gateways/%s/access/%s", url.PathEscape(gatewayID), url.PathEscape(userID)), body, &result, http.StatusOK); err != nil { + return nil, err + } + return &result, nil +} + +func (a *GatewayAccessAPI) SearchDirectory(ctx context.Context, gatewayID string, search string) (*types.GatewayDirectoryUserList, error) { + path := fmt.Sprintf("/gateways/%s/access/directory", url.PathEscape(gatewayID)) + if search != "" { + params := url.Values{} + params.Set("search", search) + path += "?" + params.Encode() + } + var result types.GatewayDirectoryUserList + if _, err := a.client.doAny(ctx, http.MethodGet, path, nil, &result, http.StatusOK); err != nil { + return nil, err + } + return &result, nil +} + +func (a *GatewayAccessAPI) ListAll(ctx context.Context, gatewayID string, opts *types.GatewayAccessListOptions) *Iterator[types.GatewayAccessListItem] { + if opts == nil { + opts = types.NewGatewayAccessListOptions().Build() + } + return NewIterator(func(page int) (*types.GatewayAccessList, error) { + copyOpts := *opts + copyOpts.Page = page + return a.List(ctx, gatewayID, ©Opts) + }) +} diff --git a/components/sdk-go/client/gateway_api.go b/components/sdk-go/client/gateway_api.go index eda4b5f6b..5236b0dc7 100644 --- a/components/sdk-go/client/gateway_api.go +++ b/components/sdk-go/client/gateway_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 package client diff --git a/components/sdk-go/client/gateway_network_api.go b/components/sdk-go/client/gateway_network_api.go index 8b6243185..a86d3a653 100644 --- a/components/sdk-go/client/gateway_network_api.go +++ b/components/sdk-go/client/gateway_network_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 package client diff --git a/components/sdk-go/client/gateway_release_api.go b/components/sdk-go/client/gateway_release_api.go index b241917c8..7565e5417 100644 --- a/components/sdk-go/client/gateway_release_api.go +++ b/components/sdk-go/client/gateway_release_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 package client diff --git a/components/sdk-go/client/iterator.go b/components/sdk-go/client/iterator.go index feaf3d6f1..775470008 100644 --- a/components/sdk-go/client/iterator.go +++ b/components/sdk-go/client/iterator.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 package client diff --git a/components/sdk-go/client/managed_cluster_api.go b/components/sdk-go/client/managed_cluster_api.go index 5ee1b3f68..66d8d7dd5 100644 --- a/components/sdk-go/client/managed_cluster_api.go +++ b/components/sdk-go/client/managed_cluster_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 package client diff --git a/components/sdk-go/client/open_shell_gateway_service_account_api.go b/components/sdk-go/client/open_shell_gateway_service_account_api.go index e8d253682..2b4a9e101 100644 --- a/components/sdk-go/client/open_shell_gateway_service_account_api.go +++ b/components/sdk-go/client/open_shell_gateway_service_account_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 package client diff --git a/components/sdk-go/client/role_api.go b/components/sdk-go/client/role_api.go index c39bdc40a..e75ae1361 100644 --- a/components/sdk-go/client/role_api.go +++ b/components/sdk-go/client/role_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 package client diff --git a/components/sdk-go/client/role_binding_api.go b/components/sdk-go/client/role_binding_api.go index 3e6559cb1..acba24248 100644 --- a/components/sdk-go/client/role_binding_api.go +++ b/components/sdk-go/client/role_binding_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 package client diff --git a/components/sdk-go/client/user_api.go b/components/sdk-go/client/user_api.go index 3da535881..caccb4bb5 100644 --- a/components/sdk-go/client/user_api.go +++ b/components/sdk-go/client/user_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 package client diff --git a/components/sdk-go/types/base.go b/components/sdk-go/types/base.go index 872c51ee5..fff485374 100644 --- a/components/sdk-go/types/base.go +++ b/components/sdk-go/types/base.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 package types diff --git a/components/sdk-go/types/gateway.go b/components/sdk-go/types/gateway.go index 22670dad6..7b44b3485 100644 --- a/components/sdk-go/types/gateway.go +++ b/components/sdk-go/types/gateway.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 package types diff --git a/components/sdk-go/types/gateway_access.go b/components/sdk-go/types/gateway_access.go new file mode 100644 index 000000000..2d564c796 --- /dev/null +++ b/components/sdk-go/types/gateway_access.go @@ -0,0 +1,131 @@ +// Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. +// Source: components/api-server/openapi/openapi.yaml +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 + +package types + +import "time" + +type GatewayAccessCapabilities struct { + CallerRole GatewayAccessRole `json:"caller_role,omitempty"` + CanManageAccess bool `json:"can_manage_access"` + CanManageOwners bool `json:"can_manage_owners"` +} + +type GatewayAccessChangeRoleRequest struct { + Role GatewayAccessRole `json:"role"` +} + +type GatewayAccessGrantRequest struct { + Role GatewayAccessRole `json:"role"` + Subject string `json:"subject,omitempty"` + Username string `json:"username"` +} + +type GatewayAccessGrantResponse struct { + Email *string `json:"email,omitempty"` + GrantedAt time.Time `json:"granted_at"` + IsCreator bool `json:"is_creator"` + Name *string `json:"name,omitempty"` + Role GatewayAccessRole `json:"role"` + RoleBindingID string `json:"role_binding_id"` + UserID string `json:"user_id"` + Username string `json:"username"` +} + +type GatewayAccessList struct { + Capabilities GatewayAccessCapabilities `json:"capabilities"` + Items []GatewayAccessListItem `json:"items"` + Page int `json:"page"` + Size int `json:"size"` + Total int `json:"total"` +} + +type GatewayAccessListItem struct { + Email *string `json:"email,omitempty"` + GrantedAt time.Time `json:"granted_at"` + IsCreator bool `json:"is_creator"` + Name *string `json:"name,omitempty"` + Role GatewayAccessRole `json:"role"` + RoleBindingID string `json:"role_binding_id"` + UserID string `json:"user_id"` + Username string `json:"username"` +} + +type GatewayAccessRole string + +const ( + GatewayAccessRoleOwner GatewayAccessRole = "owner" + GatewayAccessRoleAdmin GatewayAccessRole = "admin" + GatewayAccessRoleUser GatewayAccessRole = "user" +) + +type GatewayDirectoryUser struct { + Email *string `json:"email,omitempty"` + Name *string `json:"name,omitempty"` + Subject *string `json:"subject,omitempty"` + Username string `json:"username"` +} + +type GatewayDirectoryUserList struct { + Items []GatewayDirectoryUser `json:"items"` +} + +func (l *GatewayAccessList) GetItems() []GatewayAccessListItem { return l.Items } +func (l *GatewayAccessList) GetTotal() int { return l.Total } +func (l *GatewayAccessList) GetPage() int { return l.Page } +func (l *GatewayAccessList) GetSize() int { return l.Size } + +type GatewayAccessListOptions struct { + Page int + Size int + Role GatewayAccessRole + Search string + Sort string + Order string +} + +type GatewayAccessListOptionsBuilder struct { + opts GatewayAccessListOptions +} + +func NewGatewayAccessListOptions() *GatewayAccessListOptionsBuilder { + return &GatewayAccessListOptionsBuilder{opts: GatewayAccessListOptions{Page: 1, Size: 20}} +} + +func (b *GatewayAccessListOptionsBuilder) Page(value int) *GatewayAccessListOptionsBuilder { + b.opts.Page = value + return b +} + +func (b *GatewayAccessListOptionsBuilder) Size(value int) *GatewayAccessListOptionsBuilder { + if value > 100 { + value = 100 + } + b.opts.Size = value + return b +} + +func (b *GatewayAccessListOptionsBuilder) Role(value GatewayAccessRole) *GatewayAccessListOptionsBuilder { + b.opts.Role = value + return b +} + +func (b *GatewayAccessListOptionsBuilder) Search(value string) *GatewayAccessListOptionsBuilder { + b.opts.Search = value + return b +} + +func (b *GatewayAccessListOptionsBuilder) Sort(value string) *GatewayAccessListOptionsBuilder { + b.opts.Sort = value + return b +} + +func (b *GatewayAccessListOptionsBuilder) Order(value string) *GatewayAccessListOptionsBuilder { + b.opts.Order = value + return b +} + +func (b *GatewayAccessListOptionsBuilder) Build() *GatewayAccessListOptions { + return &b.opts +} diff --git a/components/sdk-go/types/gateway_network.go b/components/sdk-go/types/gateway_network.go index 2407f3d80..8f8d25548 100644 --- a/components/sdk-go/types/gateway_network.go +++ b/components/sdk-go/types/gateway_network.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 package types diff --git a/components/sdk-go/types/gateway_release.go b/components/sdk-go/types/gateway_release.go index 54b5d7ce1..a10692e4d 100644 --- a/components/sdk-go/types/gateway_release.go +++ b/components/sdk-go/types/gateway_release.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 package types diff --git a/components/sdk-go/types/list_options.go b/components/sdk-go/types/list_options.go index f7f39ce36..54e4ae0f2 100644 --- a/components/sdk-go/types/list_options.go +++ b/components/sdk-go/types/list_options.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 package types diff --git a/components/sdk-go/types/managed_cluster.go b/components/sdk-go/types/managed_cluster.go index e23859a4c..9ed38f1a0 100644 --- a/components/sdk-go/types/managed_cluster.go +++ b/components/sdk-go/types/managed_cluster.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 package types diff --git a/components/sdk-go/types/open_shell_gateway_service_account.go b/components/sdk-go/types/open_shell_gateway_service_account.go index 0a7ff020b..bc9f0651e 100644 --- a/components/sdk-go/types/open_shell_gateway_service_account.go +++ b/components/sdk-go/types/open_shell_gateway_service_account.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 package types diff --git a/components/sdk-go/types/role.go b/components/sdk-go/types/role.go index 456c2bf09..e06455813 100644 --- a/components/sdk-go/types/role.go +++ b/components/sdk-go/types/role.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 package types diff --git a/components/sdk-go/types/role_binding.go b/components/sdk-go/types/role_binding.go index 944626a48..2cbe4e292 100644 --- a/components/sdk-go/types/role_binding.go +++ b/components/sdk-go/types/role_binding.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 package types diff --git a/components/sdk-go/types/user.go b/components/sdk-go/types/user.go index 79bdd16ad..e327431e4 100644 --- a/components/sdk-go/types/user.go +++ b/components/sdk-go/types/user.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 package types diff --git a/components/sdk-typescript/src/base.ts b/components/sdk-typescript/src/base.ts index a83372633..025c43c8e 100644 --- a/components/sdk-typescript/src/base.ts +++ b/components/sdk-typescript/src/base.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 export type ObjectReference = { id: string; diff --git a/components/sdk-typescript/src/client.ts b/components/sdk-typescript/src/client.ts index 37cd37910..edb63ebaa 100644 --- a/components/sdk-typescript/src/client.ts +++ b/components/sdk-typescript/src/client.ts @@ -1,9 +1,10 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 import type { SDKClientConfig } from './base.js'; import { GatewayAPI } from './gateway_api.js'; +import { GatewayAccessAPI } from './gateway_access_api.js'; import { GatewayNetworkAPI } from './gateway_network_api.js'; import { GatewayReleaseAPI } from './gateway_release_api.js'; import { ManagedClusterAPI } from './managed_cluster_api.js'; @@ -17,6 +18,7 @@ export class SDKClient { private readonly config: SDKClientConfig; readonly gateways: GatewayAPI; + readonly gatewayAccesses: GatewayAccessAPI; readonly gatewayNetworks: GatewayNetworkAPI; readonly gatewayReleases: GatewayReleaseAPI; readonly managedClusters: ManagedClusterAPI; @@ -36,6 +38,7 @@ export class SDKClient { }; this.gateways = new GatewayAPI(this.config); + this.gatewayAccesses = new GatewayAccessAPI(this.config); this.gatewayNetworks = new GatewayNetworkAPI(this.config); this.gatewayReleases = new GatewayReleaseAPI(this.config); this.managedClusters = new ManagedClusterAPI(this.config); diff --git a/components/sdk-typescript/src/gateway.ts b/components/sdk-typescript/src/gateway.ts index 21db24837..0f1c89951 100644 --- a/components/sdk-typescript/src/gateway.ts +++ b/components/sdk-typescript/src/gateway.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_access.ts b/components/sdk-typescript/src/gateway_access.ts new file mode 100644 index 000000000..017966b35 --- /dev/null +++ b/components/sdk-typescript/src/gateway_access.ts @@ -0,0 +1,89 @@ +// Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. +// Source: components/api-server/openapi/openapi.yaml +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 + + +export type GatewayAccessCapabilities = { + caller_role?: GatewayAccessRole; + can_manage_access: boolean; + can_manage_owners: boolean; +}; + + + +export type GatewayAccessChangeRoleRequest = { + role: GatewayAccessRole; +}; + + + +export type GatewayAccessGrantRequest = { + role: GatewayAccessRole; + subject?: string; + username: string; +}; + + + +export type GatewayAccessGrantResponse = { + email?: string | null; + granted_at: string; + is_creator: boolean; + name?: string | null; + role: GatewayAccessRole; + role_binding_id: string; + user_id: string; + username: string; +}; + + + +export type GatewayAccessList = { + capabilities: GatewayAccessCapabilities; + items: GatewayAccessListItem[]; + page: number; + size: number; + total: number; +}; + + + +export type GatewayAccessListItem = { + email?: string | null; + granted_at: string; + is_creator: boolean; + name?: string | null; + role: GatewayAccessRole; + role_binding_id: string; + user_id: string; + username: string; +}; + + + +export type GatewayAccessRole = 'owner' | 'admin' | 'user'; + + + +export type GatewayDirectoryUser = { + email?: string | null; + name?: string | null; + subject?: string | null; + username: string; +}; + + + +export type GatewayDirectoryUserList = { + items: GatewayDirectoryUser[]; +}; + + +export type GatewayAccessListOptions = { + page?: number; + size?: number; + role?: GatewayAccessRole; + search?: string; + sort?: "username" | "name" | "role" | "granted_at"; + order?: "asc" | "desc"; +}; diff --git a/components/sdk-typescript/src/gateway_access_api.ts b/components/sdk-typescript/src/gateway_access_api.ts new file mode 100644 index 000000000..6329ebf0b --- /dev/null +++ b/components/sdk-typescript/src/gateway_access_api.ts @@ -0,0 +1,80 @@ +// Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. +// Source: components/api-server/openapi/openapi.yaml +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 + +import type { SDKClientConfig, RequestOptions } from './base.js'; +import { sdkFetch } from './base.js'; +import type { + GatewayAccessGrantRequest, + GatewayAccessGrantResponse, + GatewayAccessListItem, + GatewayAccessList, + GatewayAccessChangeRoleRequest, + GatewayDirectoryUserList, + GatewayAccessListOptions, +} from './gateway_access.js'; + +export class GatewayAccessAPI { + constructor(private readonly config: SDKClientConfig) {} + + async create(gatewayId: string, data: GatewayAccessGrantRequest, opts?: RequestOptions): Promise { + return sdkFetch(this.config, 'POST', `/gateways/${encodeURIComponent(gatewayId)}/access`, data, opts); + } + + async list(gatewayId: string, listOpts?: GatewayAccessListOptions, opts?: RequestOptions): Promise { + const params = new URLSearchParams(); + if (listOpts?.page !== undefined) { + params.set('page', String(listOpts.page)); + } + if (listOpts?.size !== undefined) { + params.set('size', String(Math.min(listOpts.size, 100))); + } + if (listOpts?.role !== undefined) { + params.set('role', String(listOpts.role)); + } + if (listOpts?.search !== undefined && listOpts.search !== '') { + params.set('search', String(listOpts.search)); + } + if (listOpts?.sort !== undefined) { + params.set('sort', String(listOpts.sort)); + } + if (listOpts?.order !== undefined) { + params.set('order', String(listOpts.order)); + } + const query = params.toString(); + const path = `/gateways/${encodeURIComponent(gatewayId)}/access` + (query ? `?${query}` : ''); + return sdkFetch(this.config, 'GET', path, undefined, opts); + } + + async delete(gatewayId: string, userId: string, opts?: RequestOptions): Promise { + return sdkFetch(this.config, 'DELETE', `/gateways/${encodeURIComponent(gatewayId)}/access/${encodeURIComponent(userId)}`, undefined, opts); + } + + async update(gatewayId: string, userId: string, data: GatewayAccessChangeRoleRequest, opts?: RequestOptions): Promise { + return sdkFetch(this.config, 'PATCH', `/gateways/${encodeURIComponent(gatewayId)}/access/${encodeURIComponent(userId)}`, data, opts); + } + + async searchDirectory(gatewayId: string, search?: string, opts?: RequestOptions): Promise { + const params = new URLSearchParams(); + if (search !== undefined && search !== '') { + params.set('search', search); + } + const query = params.toString(); + const path = `/gateways/${encodeURIComponent(gatewayId)}/access/directory` + (query ? `?${query}` : ''); + return sdkFetch(this.config, 'GET', path, undefined, opts); + } + + async *listAll(gatewayId: string, size: number = 20, opts?: RequestOptions): AsyncGenerator { + let page = 1; + while (true) { + const result = await this.list(gatewayId, { page, size }, opts); + for (const item of result.items) { + yield item; + } + if (page * size >= result.total) { + break; + } + page++; + } + } +} diff --git a/components/sdk-typescript/src/gateway_api.ts b/components/sdk-typescript/src/gateway_api.ts index a441b6b3a..884620ba1 100644 --- a/components/sdk-typescript/src/gateway_api.ts +++ b/components/sdk-typescript/src/gateway_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_network.ts b/components/sdk-typescript/src/gateway_network.ts index 5f1ac0899..15a0c62d3 100644 --- a/components/sdk-typescript/src/gateway_network.ts +++ b/components/sdk-typescript/src/gateway_network.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_network_api.ts b/components/sdk-typescript/src/gateway_network_api.ts index c8063df2f..b5b512acf 100644 --- a/components/sdk-typescript/src/gateway_network_api.ts +++ b/components/sdk-typescript/src/gateway_network_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_release.ts b/components/sdk-typescript/src/gateway_release.ts index af2b1995f..a9c72fead 100644 --- a/components/sdk-typescript/src/gateway_release.ts +++ b/components/sdk-typescript/src/gateway_release.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_release_api.ts b/components/sdk-typescript/src/gateway_release_api.ts index 3f82de6e0..2965fd3d4 100644 --- a/components/sdk-typescript/src/gateway_release_api.ts +++ b/components/sdk-typescript/src/gateway_release_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/index.ts b/components/sdk-typescript/src/index.ts index 553f04c69..8dc0ab0b6 100644 --- a/components/sdk-typescript/src/index.ts +++ b/components/sdk-typescript/src/index.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 export { SDKClient } from './client.js'; export type { SDKClientConfig, ListOptions, RequestOptions, ObjectReference, ListMeta, APIError } from './base.js'; @@ -13,6 +13,20 @@ export { GatewayAPI } from './gateway_api.js'; +export type { GatewayAccessCapabilities } from './gateway_access.js'; +export type { GatewayAccessChangeRoleRequest } from './gateway_access.js'; +export type { GatewayAccessGrantRequest } from './gateway_access.js'; +export type { GatewayAccessGrantResponse } from './gateway_access.js'; +export type { GatewayAccessList } from './gateway_access.js'; +export type { GatewayAccessListItem } from './gateway_access.js'; +export type { GatewayAccessRole } from './gateway_access.js'; +export type { GatewayDirectoryUser } from './gateway_access.js'; +export type { GatewayDirectoryUserList } from './gateway_access.js'; +export type { GatewayAccessListOptions } from './gateway_access.js'; +export { GatewayAccessAPI } from './gateway_access_api.js'; + + + export type { GatewayNetwork, GatewayNetworkList, GatewayNetworkCreateRequest, GatewayNetworkPatchRequest } from './gateway_network.js'; export { GatewayNetworkBuilder, GatewayNetworkPatchBuilder } from './gateway_network.js'; export { GatewayNetworkAPI } from './gateway_network_api.js'; diff --git a/components/sdk-typescript/src/managed_cluster.ts b/components/sdk-typescript/src/managed_cluster.ts index d1b717fff..f351e022a 100644 --- a/components/sdk-typescript/src/managed_cluster.ts +++ b/components/sdk-typescript/src/managed_cluster.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/managed_cluster_api.ts b/components/sdk-typescript/src/managed_cluster_api.ts index 7a1fded74..753a63245 100644 --- a/components/sdk-typescript/src/managed_cluster_api.ts +++ b/components/sdk-typescript/src/managed_cluster_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/open_shell_gateway_service_account.ts b/components/sdk-typescript/src/open_shell_gateway_service_account.ts index b531e1938..891ead330 100644 --- a/components/sdk-typescript/src/open_shell_gateway_service_account.ts +++ b/components/sdk-typescript/src/open_shell_gateway_service_account.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 export type OpenShellGatewayServiceAccountCapabilities = { diff --git a/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts b/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts index 35bc2ce37..f2a29ee91 100644 --- a/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts +++ b/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 import type { SDKClientConfig, RequestOptions } from './base.js'; import { sdkFetch } from './base.js'; diff --git a/components/sdk-typescript/src/role.ts b/components/sdk-typescript/src/role.ts index c001f6444..0f962ac6b 100644 --- a/components/sdk-typescript/src/role.ts +++ b/components/sdk-typescript/src/role.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/role_api.ts b/components/sdk-typescript/src/role_api.ts index 26978a57f..f9d272574 100644 --- a/components/sdk-typescript/src/role_api.ts +++ b/components/sdk-typescript/src/role_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/role_binding.ts b/components/sdk-typescript/src/role_binding.ts index d1759f54b..e13a37d0c 100644 --- a/components/sdk-typescript/src/role_binding.ts +++ b/components/sdk-typescript/src/role_binding.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/role_binding_api.ts b/components/sdk-typescript/src/role_binding_api.ts index 529b0ad54..64c3e53ed 100644 --- a/components/sdk-typescript/src/role_binding_api.ts +++ b/components/sdk-typescript/src/role_binding_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/user.ts b/components/sdk-typescript/src/user.ts index ccd03a47c..0baee78d9 100644 --- a/components/sdk-typescript/src/user.ts +++ b/components/sdk-typescript/src/user.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/user_api.ts b/components/sdk-typescript/src/user_api.ts index 95ffa0922..3144e7d92 100644 --- a/components/sdk-typescript/src/user_api.ts +++ b/components/sdk-typescript/src/user_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: efa99fee4601229ed4a076fd6e735d83abba708a1f8b58477c6f52ebae6d8e96 +// Spec SHA256: 591d548ba02b1cd7eb7a4d6a02e1fc251d77f43d5ae6c7456b3806279e638f26 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/web-console/app/adapters/api/gateway-operations.test.ts b/components/web-console/app/adapters/api/gateway-operations.test.ts index e3d3be1e0..268bcae9f 100644 --- a/components/web-console/app/adapters/api/gateway-operations.test.ts +++ b/components/web-console/app/adapters/api/gateway-operations.test.ts @@ -31,7 +31,15 @@ const serviceAccountApi = { list: vi.fn(), revoke: vi.fn(), }; +const gatewayAccessApi = { + create: vi.fn(), + delete: vi.fn(), + list: vi.fn(), + searchDirectory: vi.fn(), + update: vi.fn(), +}; const gatewayApiFactory = vi.fn(() => ({ + gatewayAccesses: gatewayAccessApi, gateways: gatewayApi, managedClusters: managedClusterApi, openShellGatewayServiceAccounts: serviceAccountApi, @@ -777,4 +785,150 @@ describe("gateway API operations adapter", () => { }); expect((failure as Error).message).not.toContain("raw provider detail"); }); + + it("maps the gateway access list to a domain page with joined fields", async () => { + gatewayAccessApi.list.mockResolvedValue({ + capabilities: { + caller_role: "owner", + can_manage_access: true, + can_manage_owners: true, + }, + items: [ + { + email: null, + granted_at: "2026-10-01T00:00:00Z", + is_creator: true, + name: "Ann Owner", + role: "owner", + role_binding_id: "rb-1", + user_id: "u-1", + username: "ann", + }, + ], + page: 1, + size: 20, + total: 1, + }); + + const page = await controlPlane.listGatewayAccess( + "gateway-1", + { order: "desc", page: 1, search: "", size: 20, sort: "granted_at" }, + context, + ); + + expect(gatewayAccessApi.list).toHaveBeenCalledWith( + "gateway-1", + { order: "desc", page: 1, size: 20, sort: "granted_at" }, + { signal: undefined }, + ); + expect(page.capabilities).toEqual({ + callerRole: "owner", + canManageAccess: true, + canManageOwners: true, + }); + expect(page.items[0]).toMatchObject({ + isCreator: true, + name: "Ann Owner", + role: "owner", + roleBindingId: "rb-1", + userId: "u-1", + username: "ann", + }); + expect(page.items[0]).not.toHaveProperty("email"); + }); + + it("grants access and maps the resulting record", async () => { + gatewayAccessApi.create.mockResolvedValue({ + email: "dana@example.com", + granted_at: "2026-10-02T00:00:00Z", + is_creator: false, + name: "Dana", + role: "user", + role_binding_id: "rb-2", + user_id: "u-2", + username: "dana", + }); + + const record = await controlPlane.grantGatewayAccess( + "gateway-1", + { role: "user", username: "dana" }, + context, + ); + + expect(gatewayAccessApi.create).toHaveBeenCalledWith( + "gateway-1", + { role: "user", username: "dana" }, + { signal: undefined }, + ); + expect(record).toMatchObject({ + email: "dana@example.com", + role: "user", + userId: "u-2", + }); + }); + + it("maps a 409 on role change to a conflict (last-owner protection)", async () => { + gatewayAccessApi.update.mockRejectedValue( + new SDKAPIError({ + code: "conflict", + href: "", + id: "", + kind: "Error", + operation_id: "op-c", + reason: "a gateway must keep at least one owner", + status_code: 409, + }), + ); + + const failure = await controlPlane + .changeGatewayAccessRole("gateway-1", "u-1", "user", context) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(GatewayOperationError); + expect(failure).toMatchObject({ kind: "conflict" }); + }); + + it("maps a 403 on revoke to denied", async () => { + gatewayAccessApi.delete.mockRejectedValue( + new SDKAPIError({ + code: "forbidden", + href: "", + id: "", + kind: "Error", + operation_id: "op-d", + reason: "only owners can revoke an owner", + status_code: 403, + }), + ); + + const failure = await controlPlane + .revokeGatewayAccess("gateway-1", "u-1", context) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(GatewayOperationError); + expect(failure).toMatchObject({ kind: "denied" }); + }); + + it("searches the directory and maps candidates", async () => { + gatewayAccessApi.searchDirectory.mockResolvedValue({ + items: [ + { email: null, name: "Dana", subject: "sub-1", username: "dana" }, + ], + }); + + const candidates = await controlPlane.searchGatewayDirectory( + "gateway-1", + "da", + context, + ); + + expect(gatewayAccessApi.searchDirectory).toHaveBeenCalledWith( + "gateway-1", + "da", + { signal: undefined }, + ); + expect(candidates).toEqual([ + { name: "Dana", subject: "sub-1", username: "dana" }, + ]); + }); }); diff --git a/components/web-console/app/adapters/api/gateway-operations.ts b/components/web-console/app/adapters/api/gateway-operations.ts index c9fa1e7e6..0fb0c519e 100644 --- a/components/web-console/app/adapters/api/gateway-operations.ts +++ b/components/web-console/app/adapters/api/gateway-operations.ts @@ -1,5 +1,9 @@ import type { + GatewayAccessCapabilities, + GatewayAccessGrantRecord, + GatewayAccessPage, GatewayControlPlane, + GatewayDirectoryUser, GatewayFailureCode, GatewayFailureKind, GatewayInvocationContext, @@ -21,6 +25,9 @@ import { import { SDKAPIError, type Gateway, + type GatewayAccessCapabilities as ApiGatewayAccessCapabilities, + type GatewayAccessListItem as ApiGatewayAccessListItem, + type GatewayDirectoryUser as ApiGatewayDirectoryUser, type ManagedCluster, type OpenShellGatewayServiceAccountCapabilities as ApiServiceAccountCapabilities, type OpenShellGatewayServiceAccountConnection as ApiServiceAccountConnection, @@ -40,7 +47,12 @@ type ServiceAccountApi = Pick< SDKClient["openShellGatewayServiceAccounts"], "create" | "delete" | "get" | "list" | "revoke" >; +type GatewayAccessApi = Pick< + SDKClient["gatewayAccesses"], + "create" | "delete" | "list" | "searchDirectory" | "update" +>; interface GatewayApiClient { + gatewayAccesses: GatewayAccessApi; gateways: GatewayApi; managedClusters: ManagedClusterApi; openShellGatewayServiceAccounts: ServiceAccountApi; @@ -198,6 +210,47 @@ type ApiServiceAccountRecord = | OpenShellGatewayServiceAccountGetResponse | OpenShellGatewayServiceAccountListItem; +function toAccessGrantRecord( + item: ApiGatewayAccessListItem, +): GatewayAccessGrantRecord { + const email = optionalString(item.email); + const name = optionalString(item.name); + return { + ...(email ? { email } : {}), + grantedAt: item.granted_at, + isCreator: item.is_creator, + ...(name ? { name } : {}), + role: item.role, + roleBindingId: item.role_binding_id, + userId: item.user_id, + username: item.username, + }; +} + +function toAccessCapabilities( + capabilities: ApiGatewayAccessCapabilities, +): GatewayAccessCapabilities { + return { + ...(capabilities.caller_role + ? { callerRole: capabilities.caller_role } + : {}), + canManageAccess: capabilities.can_manage_access, + canManageOwners: capabilities.can_manage_owners, + }; +} + +function toDirectoryUser(user: ApiGatewayDirectoryUser): GatewayDirectoryUser { + const email = optionalString(user.email); + const name = optionalString(user.name); + const subject = optionalString(user.subject); + return { + ...(email ? { email } : {}), + ...(name ? { name } : {}), + ...(subject ? { subject } : {}), + username: user.username, + }; +} + function toServiceAccountRecord( account: ApiServiceAccountRecord, ): OpenShellGatewayServiceAccountRecord { @@ -322,6 +375,83 @@ export function createGatewayControlPlaneAdapter( apiFactory: GatewayApiFactory, ): GatewayControlPlane { return { + async changeGatewayAccessRole(gatewayId, userId, role, context) { + return mapFailure(async () => { + const response = await apiClient( + apiFactory, + context, + ).gatewayAccesses.update( + gatewayId, + userId, + { role }, + { signal: context.signal }, + ); + return toAccessGrantRecord(response); + }); + }, + async grantGatewayAccess(gatewayId, input, context) { + return mapFailure(async () => { + const response = await apiClient( + apiFactory, + context, + ).gatewayAccesses.create( + gatewayId, + { + role: input.role, + ...(input.subject === undefined ? {} : { subject: input.subject }), + username: input.username, + }, + { signal: context.signal }, + ); + return toAccessGrantRecord(response); + }); + }, + async listGatewayAccess(gatewayId, request, context) { + return mapFailure(async (): Promise => { + const response = await apiClient( + apiFactory, + context, + ).gatewayAccesses.list( + gatewayId, + { + order: request.order, + page: request.page, + ...(request.role ? { role: request.role } : {}), + ...(request.search ? { search: request.search } : {}), + size: request.size, + sort: request.sort, + }, + { signal: context.signal }, + ); + return { + capabilities: toAccessCapabilities(response.capabilities), + items: response.items.map(toAccessGrantRecord), + page: response.page, + size: response.size, + total: response.total, + }; + }); + }, + async revokeGatewayAccess(gatewayId, userId, context) { + await mapFailure(() => + apiClient(apiFactory, context).gatewayAccesses.delete( + gatewayId, + userId, + { signal: context.signal }, + ), + ); + }, + async searchGatewayDirectory(gatewayId, search, context) { + return mapFailure(async () => { + const response = await apiClient( + apiFactory, + context, + ).gatewayAccesses.searchDirectory(gatewayId, search, { + signal: context.signal, + }); + return response.items.map(toDirectoryUser); + }); + }, async createOpenShellGatewayServiceAccount(gatewayId, input, context) { return mapFailure(async () => { const response = await apiClient( diff --git a/components/web-console/locales/en.json b/components/web-console/locales/en.json index d395f5214..f611bcda4 100644 --- a/components/web-console/locales/en.json +++ b/components/web-console/locales/en.json @@ -731,6 +731,186 @@ "defaultMessage": "This field is required.", "description": "Validation message for an empty required form field." }, + "app.gateway.access.actionError.denied": { + "defaultMessage": "You are not allowed to perform this action.", + "description": "Error shown when the server denies an access mutation." + }, + "app.gateway.access.actionError.generic": { + "defaultMessage": "The action could not be completed. Try again.", + "description": "Generic error shown when an access mutation fails." + }, + "app.gateway.access.actionError.lastOwner": { + "defaultMessage": "A gateway must keep at least one owner. Assign another owner before demoting or removing this one.", + "description": "Error shown when the server rejects a last-owner mutation." + }, + "app.gateway.access.addUsers": { + "defaultMessage": "Add users", + "description": "Button that opens the directory picker." + }, + "app.gateway.access.changeRole.label": { + "defaultMessage": "Change role", + "description": "Accessible label for the inline role control." + }, + "app.gateway.access.column.role": { + "defaultMessage": "Role", + "description": "Access table column for the role." + }, + "app.gateway.access.column.userId": { + "defaultMessage": "User ID", + "description": "Access table column for the username." + }, + "app.gateway.access.column.userName": { + "defaultMessage": "User name", + "description": "Access table column for the user's display name." + }, + "app.gateway.access.creatorMarker": { + "defaultMessage": "Creator", + "description": "Marker shown on the row of the gateway creator." + }, + "app.gateway.access.description": { + "defaultMessage": "Manage who can access this gateway and at what role.", + "description": "Description under the Manage access heading." + }, + "app.gateway.access.directory.clear": { + "defaultMessage": "Clear directory search", + "description": "Accessible label for clearing the directory search." + }, + "app.gateway.access.directory.error": { + "defaultMessage": "The directory could not be searched", + "description": "Error title for the directory typeahead." + }, + "app.gateway.access.directory.loading": { + "defaultMessage": "Searching directory...", + "description": "Loading sentinel for the directory typeahead." + }, + "app.gateway.access.directory.noResults": { + "defaultMessage": "No matching users", + "description": "No-results sentinel for the directory typeahead." + }, + "app.gateway.access.directory.searchLabel": { + "defaultMessage": "Search the identity directory", + "description": "Accessible label for the directory typeahead." + }, + "app.gateway.access.empty.body": { + "defaultMessage": "No users have access to this gateway yet.", + "description": "Empty-state body for the access list." + }, + "app.gateway.access.empty.title": { + "defaultMessage": "No access grants", + "description": "Empty-state title for the access list." + }, + "app.gateway.access.findPeople": { + "defaultMessage": "Find people...", + "description": "Placeholder and label for the access list search input." + }, + "app.gateway.access.grant": { + "defaultMessage": "Grant access", + "description": "Confirm button for granting access." + }, + "app.gateway.access.grantError.notFound": { + "defaultMessage": "That user was not found in the identity directory.", + "description": "Error shown when a grant target is absent from the realm." + }, + "app.gateway.access.granting": { + "defaultMessage": "Granting access...", + "description": "Spinner label while a grant is in flight." + }, + "app.gateway.access.heading": { + "defaultMessage": "Gateway access", + "description": "Heading for the Manage access tab." + }, + "app.gateway.access.lastOwnerDisabled": { + "defaultMessage": "A gateway must keep at least one owner.", + "description": "Accessible reason shown when the last owner's controls are disabled." + }, + "app.gateway.access.loadError": { + "defaultMessage": "Access could not be loaded", + "description": "Error title when the access list fails to load." + }, + "app.gateway.access.loadError.body": { + "defaultMessage": "Try refreshing the list.", + "description": "Error body when the access list fails to load." + }, + "app.gateway.access.loading": { + "defaultMessage": "Loading access...", + "description": "Spinner label while the access list loads." + }, + "app.gateway.access.noResults.body": { + "defaultMessage": "No access grants match your filters.", + "description": "No-results body for the access list." + }, + "app.gateway.access.noResults.title": { + "defaultMessage": "No matching users", + "description": "No-results title for the access list." + }, + "app.gateway.access.ownerOnlyDisabled": { + "defaultMessage": "Only owners can change or remove an owner.", + "description": "Accessible reason shown when a non-owner admin cannot act on an owner row." + }, + "app.gateway.access.refresh": { + "defaultMessage": "Refresh access list", + "description": "Accessible label for the access list refresh button." + }, + "app.gateway.access.remove": { + "defaultMessage": "Remove access", + "description": "Action that revokes a user's access." + }, + "app.gateway.access.remove.body": { + "defaultMessage": "This revokes the user's access to the gateway.", + "description": "Body of the remove-access confirmation." + }, + "app.gateway.access.remove.title": { + "defaultMessage": "Remove access for {userName}?", + "description": "Title of the remove-access confirmation." + }, + "app.gateway.access.removing": { + "defaultMessage": "Removing access...", + "description": "Spinner label while a removal is in flight." + }, + "app.gateway.access.role.admin": { + "defaultMessage": "Admin", + "description": "Admin role label." + }, + "app.gateway.access.role.admin.description": { + "defaultMessage": "Manage the gateway and grant admin or user access.", + "description": "Admin role description in the role picker." + }, + "app.gateway.access.role.owner": { + "defaultMessage": "Owner", + "description": "Owner role label." + }, + "app.gateway.access.role.owner.description": { + "defaultMessage": "Full control, including deleting the gateway and assigning other owners.", + "description": "Owner role description in the role picker." + }, + "app.gateway.access.role.user": { + "defaultMessage": "User", + "description": "User role label." + }, + "app.gateway.access.role.user.description": { + "defaultMessage": "Read-only access to the gateway.", + "description": "User role description in the role picker." + }, + "app.gateway.access.roleFilter.all": { + "defaultMessage": "All roles", + "description": "Role filter option that clears the role filter." + }, + "app.gateway.access.roleFilter.label": { + "defaultMessage": "Filter by role", + "description": "Accessible label for the role filter." + }, + "app.gateway.access.selectRole": { + "defaultMessage": "Select a role", + "description": "Label for the role radio group in the grant modal." + }, + "app.gateway.access.tab": { + "defaultMessage": "Manage access", + "description": "Label for the gateway detail Manage access tab." + }, + "app.gateway.access.viewerReadOnly": { + "defaultMessage": "You have read-only access to this list.", + "description": "Notice shown to viewers who cannot manage access." + }, "app.gateway.cliConnection": { "defaultMessage": "CLI connection", "description": "Label for a gateway's OpenShell CLI connection command." @@ -908,7 +1088,7 @@ "description": "Label for the default gateway detail Connection tab." }, "app.gateway.connection.tabsLabel": { - "defaultMessage": "Gateway connection, service accounts, and details", + "defaultMessage": "Gateway connection, manage access, service accounts, and details", "description": "Accessible label for the gateway detail tabs." }, "app.gateway.connection.waitingForProvisioning": { diff --git a/packages/gateway-management-ui/src/access/access-data.ts b/packages/gateway-management-ui/src/access/access-data.ts new file mode 100644 index 000000000..24e8d3563 --- /dev/null +++ b/packages/gateway-management-ui/src/access/access-data.ts @@ -0,0 +1,37 @@ +import type { GatewayAccessListRequest } from "../application/gateway-types"; + +export const accessSearchDebounceMilliseconds = 300; + +export function accessListQueryKey( + gatewayId: string, + request: GatewayAccessListRequest, +) { + return [ + "gateways", + "detail", + gatewayId, + "access", + "list", + request.page, + request.size, + request.search, + request.role ?? "all", + request.sort, + request.order, + ] as const; +} + +export function accessListQueryRoot(gatewayId: string) { + return ["gateways", "detail", gatewayId, "access", "list"] as const; +} + +export function accessDirectoryQueryKey(gatewayId: string, search: string) { + return [ + "gateways", + "detail", + gatewayId, + "access", + "directory", + search, + ] as const; +} diff --git a/packages/gateway-management-ui/src/access/access-page.test.tsx b/packages/gateway-management-ui/src/access/access-page.test.tsx new file mode 100644 index 000000000..970b95316 --- /dev/null +++ b/packages/gateway-management-ui/src/access/access-page.test.tsx @@ -0,0 +1,324 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { IntlProvider } from "react-intl"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + type GatewayAccessCapabilities, + type GatewayAccessGrantRecord, + type GatewayAccessPage, + type GatewayOperations, +} from "../application/gateway-types"; +import { GatewayUiProvider } from "../gateway-ui-provider"; +import { AccessPage } from "./access-page"; + +const creator: GatewayAccessGrantRecord = { + grantedAt: "2026-08-21T12:00:00Z", + isCreator: true, + name: "Olivia Owner", + role: "owner", + roleBindingId: "rb-owner", + userId: "u-owner", + username: "owner1", +}; +const secondOwner: GatewayAccessGrantRecord = { + grantedAt: "2026-08-21T12:00:00Z", + isCreator: false, + name: "Owen Two", + role: "owner", + roleBindingId: "rb-owner-2", + userId: "u-owner-2", + username: "owner2", +}; +const admin: GatewayAccessGrantRecord = { + grantedAt: "2026-08-21T12:00:00Z", + isCreator: false, + name: "Amy Admin", + role: "admin", + roleBindingId: "rb-admin", + userId: "u-admin", + username: "admin1", +}; +const user: GatewayAccessGrantRecord = { + grantedAt: "2026-08-21T12:00:00Z", + isCreator: false, + name: "Uma User", + role: "user", + roleBindingId: "rb-user", + userId: "u-user", + username: "user1", +}; + +const ownerCapabilities: GatewayAccessCapabilities = { + callerRole: "owner", + canManageAccess: true, + canManageOwners: true, +}; +const adminCapabilities: GatewayAccessCapabilities = { + callerRole: "admin", + canManageAccess: true, + canManageOwners: false, +}; +const viewerCapabilities: GatewayAccessCapabilities = { + callerRole: "user", + canManageAccess: false, + canManageOwners: false, +}; + +function accessPage( + items: readonly GatewayAccessGrantRecord[], + capabilities: GatewayAccessCapabilities, +): GatewayAccessPage { + return { capabilities, items, page: 1, size: 20, total: items.length }; +} + +const mocks = vi.hoisted(() => ({ + change: vi.fn(), + grant: vi.fn(), + list: vi.fn(), + revoke: vi.fn(), + search: vi.fn(), +})); + +const operations: GatewayOperations = { + changeGatewayAccessRole: mocks.change, + createOpenShellGatewayServiceAccount: vi.fn(), + deleteOpenShellGatewayServiceAccount: vi.fn(), + findGatewayPlacements: vi.fn(), + getGateway: vi.fn(), + getGatewayPlacement: vi.fn(), + getGatewayPlacements: vi.fn(), + getOpenShellGatewayServiceAccount: vi.fn(), + grantGatewayAccess: mocks.grant, + listGatewayAccess: mocks.list, + listGateways: vi.fn(), + listOpenShellGatewayServiceAccounts: vi.fn(), + provisionGateway: vi.fn(), + removeGateway: vi.fn(), + renameGateway: vi.fn(), + revokeGatewayAccess: mocks.revoke, + revokeOpenShellGatewayServiceAccount: vi.fn(), + searchGatewayDirectory: mocks.search, +}; + +function renderPage() { + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + queries: { retry: false }, + }, + }); + return render( + + + `/gateways/${id}`, + navigate: vi.fn(), + }} + > + + + + , + ); +} + +describe("AccessPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.list.mockResolvedValue( + accessPage([creator, secondOwner, admin, user], ownerCapabilities), + ); + mocks.search.mockResolvedValue([]); + mocks.grant.mockResolvedValue(user); + mocks.change.mockResolvedValue({ ...user, role: "admin" }); + mocks.revoke.mockResolvedValue(undefined); + }); + + it("renders names, user IDs, roles, and marks the creator", async () => { + renderPage(); + expect(await screen.findByText("Olivia Owner")).toBeTruthy(); + expect(screen.getByText("owner1")).toBeTruthy(); + expect(screen.getByText("Amy Admin")).toBeTruthy(); + expect(screen.getByText("Uma User")).toBeTruthy(); + // Exactly one Creator marker. + expect(screen.getAllByText("Creator")).toHaveLength(1); + }); + + it("disables the sole owner's controls", async () => { + mocks.list.mockResolvedValue(accessPage([creator], ownerCapabilities)); + renderPage(); + await screen.findByText("Olivia Owner"); + // Both the inline role control and the remove action carry the reason. + const controls = screen.getAllByRole("button", { + name: "A gateway must keep at least one owner.", + }); + expect(controls.length).toBeGreaterThanOrEqual(1); + controls.forEach((control) => { + expect(control.disabled).toBe(true); + }); + }); + + it("disables owner rows for a non-owner admin", async () => { + mocks.list.mockResolvedValue( + accessPage([creator, user], adminCapabilities), + ); + renderPage(); + await screen.findByText("Olivia Owner"); + // The owner row's controls are disabled with the owner-only reason. + const ownerControls = screen.getAllByRole("button", { + name: "Only owners can change or remove an owner.", + }); + expect(ownerControls.length).toBeGreaterThanOrEqual(1); + ownerControls.forEach((control) => { + expect(control.disabled).toBe(true); + }); + // The user row remains actionable. + expect( + screen.getByRole("button", { + name: "Remove access: Uma User", + }).disabled, + ).toBe(false); + }); + + it("filters by search text and role", async () => { + const user1 = userEvent.setup(); + renderPage(); + await screen.findByText("Olivia Owner"); + + await user1.type( + screen.getByRole("textbox", { name: "Find people..." }), + "ali", + ); + await waitFor( + () => { + expect(mocks.list).toHaveBeenLastCalledWith( + "gateway-1", + expect.objectContaining({ search: "ali" }), + expect.any(AbortSignal), + ); + }, + { timeout: 2_000 }, + ); + + await user1.click(screen.getByRole("button", { name: "Filter by role" })); + await user1.click(screen.getByRole("option", { name: "User" })); + await waitFor(() => { + expect(mocks.list).toHaveBeenLastCalledWith( + "gateway-1", + expect.objectContaining({ role: "user" }), + expect.any(AbortSignal), + ); + }); + }); + + it("changes a role inline", async () => { + const user1 = userEvent.setup(); + mocks.list.mockResolvedValue(accessPage([user], ownerCapabilities)); + renderPage(); + await screen.findByText("Uma User"); + + await user1.click( + screen.getByRole("button", { name: "Change role: Uma User" }), + ); + await user1.click(screen.getByRole("option", { name: "Admin" })); + await waitFor(() => { + expect(mocks.change).toHaveBeenCalledWith("gateway-1", "u-user", "admin"); + }); + }); + + it("removes access with confirmation", async () => { + const user1 = userEvent.setup(); + mocks.list.mockResolvedValue(accessPage([user], ownerCapabilities)); + renderPage(); + await screen.findByText("Uma User"); + + await user1.click( + screen.getByRole("button", { name: "Remove access: Uma User" }), + ); + const dialog = screen.getByRole("dialog", { + name: "Remove access for Uma User?", + }); + await user1.click( + within(dialog).getByRole("button", { name: "Remove access" }), + ); + await waitFor(() => { + expect(mocks.revoke).toHaveBeenCalledWith("gateway-1", "u-user"); + }); + }); + + it("adds a directory user with the owner-only Owner option", async () => { + const user1 = userEvent.setup(); + mocks.search.mockResolvedValue([{ name: "Dana Scully", username: "dana" }]); + renderPage(); + await screen.findByText("Olivia Owner"); + + await user1.click(screen.getByRole("button", { name: "Add users" })); + const dialog = screen.getByRole("dialog", { name: "Add users" }); + await user1.click( + within(dialog).getByRole("combobox", { + name: "Search the identity directory", + }), + ); + await user1.click(await screen.findByText(/Dana Scully/u)); + + // Owner is offered to owner callers. + expect(within(dialog).getByRole("radio", { name: /Owner/u })).toBeTruthy(); + await user1.click(within(dialog).getByRole("radio", { name: /Owner/u })); + await user1.click( + within(dialog).getByRole("button", { name: "Grant access" }), + ); + await waitFor(() => { + expect(mocks.grant).toHaveBeenCalledWith("gateway-1", { + role: "owner", + username: "dana", + }); + }); + }); + + it("hides the Owner option from a non-owner admin", async () => { + const user1 = userEvent.setup(); + mocks.list.mockResolvedValue(accessPage([admin], adminCapabilities)); + mocks.search.mockResolvedValue([{ name: "Dee", username: "dee" }]); + renderPage(); + await screen.findByText("Amy Admin"); + + await user1.click(screen.getByRole("button", { name: "Add users" })); + const dialog = screen.getByRole("dialog", { name: "Add users" }); + await user1.click( + within(dialog).getByRole("combobox", { + name: "Search the identity directory", + }), + ); + await user1.click(await screen.findByText(/Dee/u)); + expect(within(dialog).queryByRole("radio", { name: /Owner/u })).toBeNull(); + expect(within(dialog).getByRole("radio", { name: /Admin/u })).toBeTruthy(); + expect(within(dialog).getByRole("radio", { name: /User/u })).toBeTruthy(); + }); + + it("shows viewers a read-only list", async () => { + mocks.list.mockResolvedValue( + accessPage([creator, user], viewerCapabilities), + ); + renderPage(); + await screen.findByText("Olivia Owner"); + + expect( + screen.getByText("You have read-only access to this list."), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Add users" })).toBeNull(); + expect( + screen.queryByRole("button", { name: "Remove access: Uma User" }), + ).toBeNull(); + expect( + screen.queryByRole("button", { name: "Change role: Uma User" }), + ).toBeNull(); + // Role shows as plain text for viewers. + expect(screen.getByText("User")).toBeTruthy(); + }); +}); diff --git a/packages/gateway-management-ui/src/access/access-page.tsx b/packages/gateway-management-ui/src/access/access-page.tsx new file mode 100644 index 000000000..e91f64870 --- /dev/null +++ b/packages/gateway-management-ui/src/access/access-page.tsx @@ -0,0 +1,404 @@ +import { + Alert, + Button, + Content, + Flex, + FlexItem, + Label, + MenuToggle, + Select, + SelectList, + SelectOption, + Spinner, + Split, + SplitItem, + Stack, + StackItem, + Title, + type MenuToggleElement, +} from "@patternfly/react-core"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { useIntl } from "react-intl"; + +import { + defaultGatewayAccessListRequest, + gatewayAccessPageSizes, + GatewayOperationError, + type GatewayAccessGrantRecord, + type GatewayAccessListRequest, + type GatewayAccessRole, +} from "../application/gateway-types"; +import { useGatewayUi } from "../gateway-ui-provider"; +import { messages } from "../messages"; +import { ResourceRefreshButton } from "../shared/resource-refresh-button"; +import { + ResourceTable, + type ResourceTableColumn, + type ResourceTableState, + type ResourceTableStateChangeReason, +} from "../shared/resource-table"; +import { useDebouncedValue } from "../shared/use-debounced-value"; +import { + accessListQueryKey, + accessSearchDebounceMilliseconds, +} from "./access-data"; +import { AccessRemoveAction, AccessRoleControl } from "./access-row-actions"; +import { roleLabel, rowDisabledReason } from "./access-role"; +import { AddUsersDialog } from "./add-users-dialog"; + +function RoleFilter({ + onChange, + value, +}: { + onChange: (role: GatewayAccessRole | undefined) => void; + value: GatewayAccessRole | undefined; +}) { + const intl = useIntl(); + const [isOpen, setIsOpen] = useState(false); + const label = value + ? roleLabel(intl.formatMessage, value) + : intl.formatMessage(messages.accessRoleFilterAll); + const toggle = (toggleRef: React.Ref) => ( + { + setIsOpen((open) => !open); + }} + ref={toggleRef} + > + {label} + + ); + return ( + + ); +} + +export interface AccessPageProps { + collectionState?: GatewayAccessListRequest; + gatewayId: string; + isActive?: boolean; + onCollectionStateChange?: ( + state: GatewayAccessListRequest, + reason: ResourceTableStateChangeReason, + ) => void; +} + +export function AccessPage({ + collectionState, + gatewayId, + isActive = true, + onCollectionStateChange, +}: AccessPageProps) { + const intl = useIntl(); + const { gateways } = useGatewayUi(); + const [isAddOpen, setIsAddOpen] = useState(false); + const [actionError, setActionError] = useState(null); + const [localState, setLocalState] = useState({ + ...defaultGatewayAccessListRequest, + }); + const currentState = collectionState ?? localState; + const debouncedSearch = useDebouncedValue( + currentState.search.trim(), + accessSearchDebounceMilliseconds, + ); + const request = useMemo( + () => ({ ...currentState, search: debouncedSearch }), + [currentState, debouncedSearch], + ); + const access = useQuery({ + enabled: isActive, + placeholderData: keepPreviousData, + queryFn: ({ signal }) => + gateways.listGatewayAccess(gatewayId, request, signal), + queryKey: accessListQueryKey(gatewayId, request), + retry: (failureCount, error) => + failureCount < 1 && + (!(error instanceof GatewayOperationError) || + error.kind === "unavailable"), + staleTime: 5_000, + }); + const page = access.data; + const capabilities = page?.capabilities; + const canManage = capabilities?.canManageAccess ?? false; + const ownerCount = useMemo( + () => (page?.items ?? []).filter((item) => item.role === "owner").length, + [page], + ); + + const changeState = ( + next: GatewayAccessListRequest, + reason: ResourceTableStateChangeReason, + ) => { + if (onCollectionStateChange) { + onCollectionStateChange(next, reason); + } else { + setLocalState(next); + } + }; + const tableState: ResourceTableState = { + page: currentState.page, + pageSize: currentState.size, + query: currentState.search, + sortColumnId: currentState.sort, + sortDirection: currentState.order, + }; + const changeTableState = ( + next: ResourceTableState, + reason: ResourceTableStateChangeReason, + ) => { + changeState( + { + ...currentState, + page: next.page, + search: next.query, + size: next.pageSize, + }, + reason, + ); + }; + + const columns: readonly ResourceTableColumn[] = [ + { + id: "name", + label: intl.formatMessage(messages.accessUserName), + render: (grant) => ( + + + {grant.name?.trim() ? grant.name : grant.username} + + {grant.isCreator ? ( + + + + ) : null} + + ), + sortable: false, + width: 35, + }, + { + id: "username", + label: intl.formatMessage(messages.accessUserId), + render: (grant) => grant.username, + sortable: false, + width: 35, + }, + { + id: "role", + label: intl.formatMessage(messages.accessRoleColumn), + render: (grant) => + canManage && capabilities ? ( + + ) : ( + roleLabel(intl.formatMessage, grant.role) + ), + sortable: false, + width: 30, + }, + ]; + + const hasActiveFilters = + currentState.search.trim().length > 0 || currentState.role !== undefined; + + return ( + + + + + + {intl.formatMessage(messages.accessHeading)} + + + {intl.formatMessage(messages.accessDescription)} + + + + access.refetch()} + /> + + + + {capabilities && !canManage ? ( + + + + ) : null} + {actionError ? ( + + { + setActionError(null); + }} + variant="plain" + > + {intl.formatMessage(messages.cancel)} + + } + isInline + title={actionError} + variant="danger" + /> + + ) : null} + {access.isError ? ( + + void access.refetch()} + variant="link" + > + {intl.formatMessage(messages.retry)} + + } + isInline + title={intl.formatMessage(messages.accessLoadError)} + variant="danger" + > + {intl.formatMessage(messages.accessLoadErrorBody)} + + + ) : null} + {!page && access.isPending ? ( + + + + ) : null} + {page ? ( + + { + changeState({ ...currentState, page: 1, role }, "filter"); + }} + value={currentState.role} + /> + } + getRowKey={(grant) => grant.userId} + hasActiveFilters={hasActiveFilters} + id="gateway-access" + itemCount={page.total} + labels={{ + actions: intl.formatMessage(messages.actions), + clearFilters: intl.formatMessage(messages.clearFilters), + emptyBody: intl.formatMessage(messages.accessEmptyBody), + emptyTitle: intl.formatMessage(messages.accessEmptyTitle), + noResultsBody: intl.formatMessage(messages.accessNoResultsBody), + noResultsTitle: intl.formatMessage(messages.accessNoResultsTitle), + resultsCountContext: intl.formatMessage(messages.results), + searchAriaLabel: intl.formatMessage(messages.accessFindPeople), + searchPlaceholder: intl.formatMessage(messages.accessFindPeople), + }} + onClearFilters={() => { + changeState( + { ...currentState, page: 1, role: undefined, search: "" }, + "filter", + ); + }} + onStateChange={changeTableState} + pageSizeOptions={gatewayAccessPageSizes} + primaryAction={ + canManage ? ( + + ) : undefined + } + renderRowAction={ + canManage && capabilities + ? (grant) => ( + + ) + : undefined + } + rows={page.items} + state={tableState} + /> + + ) : null} + {isAddOpen && capabilities ? ( + { + setIsAddOpen(false); + }} + /> + ) : null} + + ); +} diff --git a/packages/gateway-management-ui/src/access/access-role.ts b/packages/gateway-management-ui/src/access/access-role.ts new file mode 100644 index 000000000..21bac8657 --- /dev/null +++ b/packages/gateway-management-ui/src/access/access-role.ts @@ -0,0 +1,96 @@ +import type { useIntl } from "react-intl"; + +import { + GatewayOperationError, + type GatewayAccessCapabilities, + type GatewayAccessGrantRecord, + type GatewayAccessRole, +} from "../application/gateway-types"; +import { messages } from "../messages"; + +type FormatMessage = ReturnType["formatMessage"]; + +/** Ascending-privilege order used to render role pickers. */ +export const accessRoleOrder: readonly GatewayAccessRole[] = [ + "user", + "admin", + "owner", +]; + +export function roleLabel( + formatMessage: FormatMessage, + role: GatewayAccessRole, +): string { + const descriptor = { + admin: messages.accessRoleAdmin, + owner: messages.accessRoleOwner, + user: messages.accessRoleUser, + }[role]; + return formatMessage(descriptor); +} + +export function roleDescription( + formatMessage: FormatMessage, + role: GatewayAccessRole, +): string { + const descriptor = { + admin: messages.accessRoleAdminDescription, + owner: messages.accessRoleOwnerDescription, + user: messages.accessRoleUserDescription, + }[role]; + return formatMessage(descriptor); +} + +/** Roles the caller may assign given their capabilities (GAM-08). */ +export function assignableRoles( + capabilities: GatewayAccessCapabilities, +): readonly GatewayAccessRole[] { + if (!capabilities.canManageAccess) { + return []; + } + return capabilities.canManageOwners + ? accessRoleOrder + : accessRoleOrder.filter((role) => role !== "owner"); +} + +/** + * The accessible reason a row's management controls are disabled, or null when + * the caller may act on the row. Mirrors the server guarantee (GAM-UI-08); the + * server stays authoritative. + * + * ponytail: ownerCount is counted from the current page, so last-owner disabling + * is best-effort when owners span pages; the server 409 is the backstop. + */ +export function rowDisabledReason( + formatMessage: FormatMessage, + grant: GatewayAccessGrantRecord, + capabilities: GatewayAccessCapabilities, + ownerCount: number, +): string | null { + if (grant.role === "owner" && !capabilities.canManageOwners) { + return formatMessage(messages.accessOwnerOnlyDisabled); + } + if (grant.role === "owner" && ownerCount <= 1) { + return formatMessage(messages.accessLastOwnerDisabled); + } + return null; +} + +/** Maps a failed access mutation to a localized, actionable message. */ +export function actionErrorMessage( + formatMessage: FormatMessage, + error: unknown, +): string { + if (error instanceof GatewayOperationError) { + if (error.kind === "conflict") { + return formatMessage(messages.accessActionErrorLastOwner); + } + if (error.kind === "denied") { + return formatMessage(messages.accessActionErrorDenied); + } + if (error.kind === "not-found") { + return formatMessage(messages.accessGrantErrorNotFound); + } + } + return formatMessage(messages.accessActionErrorGeneric); +} diff --git a/packages/gateway-management-ui/src/access/access-row-actions.tsx b/packages/gateway-management-ui/src/access/access-row-actions.tsx new file mode 100644 index 000000000..7f3d0975e --- /dev/null +++ b/packages/gateway-management-ui/src/access/access-row-actions.tsx @@ -0,0 +1,213 @@ +import { + Alert, + Button, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalVariant, + Select, + SelectList, + SelectOption, + MenuToggle, + type MenuToggleElement, +} from "@patternfly/react-core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useId, useState } from "react"; +import { useIntl } from "react-intl"; + +import type { + GatewayAccessCapabilities, + GatewayAccessGrantRecord, + GatewayAccessRole, +} from "../application/gateway-types"; +import { useGatewayUi } from "../gateway-ui-provider"; +import { messages } from "../messages"; +import { accessListQueryRoot } from "./access-data"; +import { actionErrorMessage, assignableRoles, roleLabel } from "./access-role"; + +function userDisplayName(grant: GatewayAccessGrantRecord): string { + return grant.name?.trim() ? grant.name : grant.username; +} + +export function AccessRoleControl({ + capabilities, + disabledReason, + gatewayId, + grant, + onActionError, +}: { + capabilities: GatewayAccessCapabilities; + disabledReason: string | null; + gatewayId: string; + grant: GatewayAccessGrantRecord; + onActionError: (message: string | null) => void; +}) { + const intl = useIntl(); + const { gateways } = useGatewayUi(); + const queryClient = useQueryClient(); + const [isOpen, setIsOpen] = useState(false); + const roles = assignableRoles(capabilities); + const mutation = useMutation({ + mutationFn: (role: GatewayAccessRole) => + gateways.changeGatewayAccessRole(gatewayId, grant.userId, role), + onError: (error) => { + onActionError(actionErrorMessage(intl.formatMessage, error)); + }, + onSuccess: async () => { + onActionError(null); + await queryClient.invalidateQueries({ + queryKey: accessListQueryRoot(gatewayId), + }); + }, + retry: false, + }); + + const disabled = disabledReason !== null || mutation.isPending; + const label = roleLabel(intl.formatMessage, grant.role); + const controlLabel = `${intl.formatMessage(messages.accessChangeRoleLabel)}: ${userDisplayName(grant)}`; + + const toggle = (toggleRef: React.Ref) => ( + { + setIsOpen((open) => !open); + }} + ref={toggleRef} + title={disabledReason ?? undefined} + variant="plainText" + > + {label} + + ); + + return ( + + ); +} + +export function AccessRemoveAction({ + disabledReason, + gatewayId, + grant, +}: { + disabledReason: string | null; + gatewayId: string; + grant: GatewayAccessGrantRecord; +}) { + const intl = useIntl(); + const { gateways } = useGatewayUi(); + const queryClient = useQueryClient(); + const descriptionId = useId(); + const titleId = useId(); + const [isOpen, setIsOpen] = useState(false); + const mutation = useMutation({ + mutationFn: () => gateways.revokeGatewayAccess(gatewayId, grant.userId), + onSuccess: async () => { + setIsOpen(false); + await queryClient.invalidateQueries({ + queryKey: accessListQueryRoot(gatewayId), + }); + }, + retry: false, + }); + const close = () => { + mutation.reset(); + setIsOpen(false); + }; + const removeLabel = + disabledReason ?? + `${intl.formatMessage(messages.accessRemove)}: ${userDisplayName(grant)}`; + + return ( + <> + + {isOpen ? ( + + + +

+ {intl.formatMessage(messages.accessRemoveBody)} +

+ {mutation.isError ? ( + + ) : null} +
+ + + + +
+ ) : null} + + ); +} diff --git a/packages/gateway-management-ui/src/access/add-users-dialog.tsx b/packages/gateway-management-ui/src/access/add-users-dialog.tsx new file mode 100644 index 000000000..4043c36df --- /dev/null +++ b/packages/gateway-management-ui/src/access/add-users-dialog.tsx @@ -0,0 +1,331 @@ +import { + Alert, + AlertActionLink, + Button, + Form, + FormGroup, + MenuToggle, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalVariant, + Radio, + Select, + SelectList, + SelectOption, + Stack, + StackItem, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + type MenuToggleElement, +} from "@patternfly/react-core"; +import RhMicronsCloseIcon from "@patternfly/react-icons/dist/esm/icons/rh-microns-close-icon"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useId, useState } from "react"; +import { useIntl } from "react-intl"; + +import type { + GatewayAccessCapabilities, + GatewayAccessRole, + GatewayDirectoryUser, +} from "../application/gateway-types"; +import { useGatewayUi } from "../gateway-ui-provider"; +import { messages } from "../messages"; +import { useDebouncedValue } from "../shared/use-debounced-value"; +import { + accessDirectoryQueryKey, + accessListQueryRoot, + accessSearchDebounceMilliseconds, +} from "./access-data"; +import { + actionErrorMessage, + assignableRoles, + roleDescription, + roleLabel, +} from "./access-role"; + +const loadingValue = Symbol(); +const noResultsValue = Symbol(); + +function DirectoryTypeahead({ + gatewayId, + onSelect, + selected, +}: { + gatewayId: string; + onSelect: (user: GatewayDirectoryUser) => void; + selected: GatewayDirectoryUser | null; +}) { + const intl = useIntl(); + const { gateways } = useGatewayUi(); + const [isOpen, setIsOpen] = useState(false); + const [inputValue, setInputValue] = useState(selected?.username ?? ""); + const [searchValue, setSearchValue] = useState(""); + const normalizedSearch = searchValue.trim(); + const debouncedSearch = useDebouncedValue( + normalizedSearch, + accessSearchDebounceMilliseconds, + ); + const isSearchPending = normalizedSearch !== debouncedSearch; + const directory = useQuery({ + queryFn: ({ signal }) => + gateways.searchGatewayDirectory(gatewayId, debouncedSearch, signal), + queryKey: accessDirectoryQueryKey(gatewayId, debouncedSearch), + staleTime: 10_000, + }); + + const candidates = !isSearchPending ? (directory.data ?? []) : []; + const options = candidates.map((candidate) => ({ + key: candidate.username, + label: candidate.name?.trim() ? candidate.name : candidate.username, + user: candidate, + })); + + const choose = (username: string) => { + const match = options.find((option) => option.key === username); + if (!match) { + return; + } + onSelect(match.user); + setInputValue(match.label); + setSearchValue(""); + setIsOpen(false); + }; + + const toggle = (toggleRef: React.Ref) => ( + { + setIsOpen((open) => !open); + }} + ref={toggleRef} + variant="typeahead" + > + + { + setInputValue(value); + setSearchValue(value); + setIsOpen(true); + }} + onClick={() => { + setIsOpen(true); + }} + placeholder={intl.formatMessage(messages.accessFindPeople)} + role="combobox" + value={inputValue} + /> + {inputValue ? ( + + + + + + ); +} diff --git a/packages/gateway-management-ui/src/application/gateway-operations.test.ts b/packages/gateway-management-ui/src/application/gateway-operations.test.ts index 8ba82b969..e7d6b1ea5 100644 --- a/packages/gateway-management-ui/src/application/gateway-operations.test.ts +++ b/packages/gateway-management-ui/src/application/gateway-operations.test.ts @@ -34,7 +34,19 @@ function setup() { total: 1, }); const renameGateway = vi.fn().mockResolvedValue(gateway); + const grant = { + grantedAt: "2026-08-06T18:00:00.000Z", + isCreator: false, + role: "user" as const, + roleBindingId: "rb-1", + userId: "user-1", + username: "dana", + }; + const changeGatewayAccessRole = vi.fn().mockResolvedValue(grant); + const grantGatewayAccess = vi.fn().mockResolvedValue(grant); const controlPlane: GatewayControlPlane = { + changeGatewayAccessRole, + grantGatewayAccess, createOpenShellGatewayServiceAccount: vi.fn(), deleteOpenShellGatewayServiceAccount: vi.fn(), findGatewayPlacements: vi.fn().mockResolvedValue({ @@ -55,12 +67,21 @@ function setup() { }, ]), getOpenShellGatewayServiceAccount: vi.fn(), + listGatewayAccess: vi.fn().mockResolvedValue({ + capabilities: { canManageAccess: true, canManageOwners: true }, + items: [], + page: 1, + size: 20, + total: 0, + }), listGateways, listOpenShellGatewayServiceAccounts: vi.fn(), provisionGateway: vi.fn().mockResolvedValue(gateway), removeGateway: vi.fn().mockResolvedValue(undefined), renameGateway, + revokeGatewayAccess: vi.fn().mockResolvedValue(undefined), revokeOpenShellGatewayServiceAccount: vi.fn(), + searchGatewayDirectory: vi.fn().mockResolvedValue([]), }; const operations = createGatewayOperations({ controlPlane, @@ -83,7 +104,15 @@ function setup() { }, }); - return { controlPlane, listGateways, operations, received, renameGateway }; + return { + changeGatewayAccessRole, + controlPlane, + grantGatewayAccess, + listGateways, + operations, + received, + renameGateway, + }; } describe("gateway application operations", () => { @@ -172,6 +201,40 @@ describe("gateway application operations", () => { "account-1", ), ], + [ + "list-access", + (operations: ReturnType["operations"]) => + operations.listGatewayAccess("gateway-1", { + order: "desc", + page: 1, + search: "", + size: 20, + sort: "granted_at", + }), + ], + [ + "grant-access", + (operations: ReturnType["operations"]) => + operations.grantGatewayAccess("gateway-1", { + role: "user", + username: "dana", + }), + ], + [ + "change-access-role", + (operations: ReturnType["operations"]) => + operations.changeGatewayAccessRole("gateway-1", "user-1", "admin"), + ], + [ + "revoke-access", + (operations: ReturnType["operations"]) => + operations.revokeGatewayAccess("gateway-1", "user-1"), + ], + [ + "search-directory", + (operations: ReturnType["operations"]) => + operations.searchGatewayDirectory("gateway-1", "da"), + ], ] as const)( "publishes one successful %s workflow and dependency outcome", async (action, invoke) => { @@ -235,6 +298,48 @@ describe("gateway application operations", () => { ).toEqual(["operation-1", "operation-1"]); }); + it("publishes a conflicted outcome when a last-owner change is rejected", async () => { + const { changeGatewayAccessRole, operations, received } = setup(); + const failure = new GatewayOperationError("conflict", { + operationId: "operation-9", + }); + changeGatewayAccessRole.mockRejectedValue(failure); + + await expect( + operations.changeGatewayAccessRole("gateway-1", "user-1", "user"), + ).rejects.toBe(failure); + + expect(received.slice(-2).map(({ fields }) => fields)).toEqual([ + { + action: "change-access-role", + failureKind: "conflict", + outcome: "conflicted", + }, + { + action: "change-access-role", + failureKind: "conflict", + outcome: "conflicted", + }, + ]); + }); + + it("publishes a denied outcome when a grant is forbidden", async () => { + const { grantGatewayAccess, operations, received } = setup(); + const failure = new GatewayOperationError("denied"); + grantGatewayAccess.mockRejectedValue(failure); + + await expect( + operations.grantGatewayAccess("gateway-1", { + role: "owner", + username: "erin", + }), + ).rejects.toBe(failure); + + expect(received.slice(-1).map(({ fields }) => fields)).toEqual([ + { action: "grant-access", failureKind: "denied", outcome: "denied" }, + ]); + }); + it("publishes cancellation without turning it into an application error", async () => { const { listGateways, operations, received } = setup(); const cancellation = Object.assign(new Error("cancelled"), { diff --git a/packages/gateway-management-ui/src/application/gateway-operations.ts b/packages/gateway-management-ui/src/application/gateway-operations.ts index 08e5f090d..67cf44b38 100644 --- a/packages/gateway-management-ui/src/application/gateway-operations.ts +++ b/packages/gateway-management-ui/src/application/gateway-operations.ts @@ -176,6 +176,10 @@ export function createGatewayOperations({ } return { + changeGatewayAccessRole: (gatewayId, userId, role, signal) => + execute("change-access-role", signal, (context) => + controlPlane.changeGatewayAccessRole(gatewayId, userId, role, context), + ), createOpenShellGatewayServiceAccount: (gatewayId, input, signal) => execute("create-service-account", signal, (context) => controlPlane.createOpenShellGatewayServiceAccount( @@ -212,6 +216,14 @@ export function createGatewayOperations({ execute("get", signal, (context) => controlPlane.getGateway(gatewayId, context), ), + grantGatewayAccess: (gatewayId, input, signal) => + execute("grant-access", signal, (context) => + controlPlane.grantGatewayAccess(gatewayId, input, context), + ), + listGatewayAccess: (gatewayId, request, signal) => + execute("list-access", signal, (context) => + controlPlane.listGatewayAccess(gatewayId, request, context), + ), getOpenShellGatewayServiceAccount: (gatewayId, serviceAccountId, signal) => execute("get-service-account", signal, (context) => controlPlane.getOpenShellGatewayServiceAccount( @@ -244,6 +256,14 @@ export function createGatewayOperations({ execute("rename", signal, (context) => controlPlane.renameGateway(gatewayId, name, context), ), + revokeGatewayAccess: (gatewayId, userId, signal) => + execute("revoke-access", signal, (context) => + controlPlane.revokeGatewayAccess(gatewayId, userId, context), + ), + searchGatewayDirectory: (gatewayId, search, signal) => + execute("search-directory", signal, (context) => + controlPlane.searchGatewayDirectory(gatewayId, search.trim(), context), + ), revokeOpenShellGatewayServiceAccount: ( gatewayId, serviceAccountId, diff --git a/packages/gateway-management-ui/src/application/gateway-probes.ts b/packages/gateway-management-ui/src/application/gateway-probes.ts index b80388b57..e6e906626 100644 --- a/packages/gateway-management-ui/src/application/gateway-probes.ts +++ b/packages/gateway-management-ui/src/application/gateway-probes.ts @@ -6,19 +6,24 @@ import type { import type { GatewayFailureKind } from "./gateway-types"; export type GatewayAction = + | "change-access-role" | "create-service-account" | "delete-service-account" | "find-placements" | "get" | "get-placement" | "get-placements" + | "grant-access" | "list" + | "list-access" | "list-service-accounts" | "provision" | "remove" | "rename" | "get-service-account" - | "revoke-service-account"; + | "revoke-access" + | "revoke-service-account" + | "search-directory"; export type GatewayProbeOutcome = "started" | "succeeded" | "failed" | "cancelled" | "denied" | "conflicted"; export type GatewayProbeName = diff --git a/packages/gateway-management-ui/src/application/gateway-types.ts b/packages/gateway-management-ui/src/application/gateway-types.ts index eff7e63bd..efa00599b 100644 --- a/packages/gateway-management-ui/src/application/gateway-types.ts +++ b/packages/gateway-management-ui/src/application/gateway-types.ts @@ -193,6 +193,69 @@ export const defaultOpenShellGatewayServiceAccountListRequest: Readonly = + Object.freeze({ + order: "desc", + page: 1, + search: "", + size: gatewayAccessPageSizes[1], + sort: "granted_at", + }); + export type GatewayFailureKind = "cancelled" | "conflict" | "denied" | "not-found" | "unavailable" | "unknown"; @@ -220,11 +283,37 @@ export class GatewayOperationError extends Error { /** Application-owned driven port for the HyperShell gateway control plane. */ export interface GatewayControlPlane { + changeGatewayAccessRole( + gatewayId: string, + userId: string, + role: GatewayAccessRole, + context: GatewayInvocationContext, + ): Promise; createOpenShellGatewayServiceAccount( gatewayId: string, input: OpenShellGatewayServiceAccountCreateInput, context: GatewayInvocationContext, ): Promise; + grantGatewayAccess( + gatewayId: string, + input: GatewayAccessGrantInput, + context: GatewayInvocationContext, + ): Promise; + listGatewayAccess( + gatewayId: string, + request: GatewayAccessListRequest, + context: GatewayInvocationContext, + ): Promise; + revokeGatewayAccess( + gatewayId: string, + userId: string, + context: GatewayInvocationContext, + ): Promise; + searchGatewayDirectory( + gatewayId: string, + search: string, + context: GatewayInvocationContext, + ): Promise; deleteOpenShellGatewayServiceAccount( gatewayId: string, serviceAccountId: string, @@ -282,11 +371,37 @@ export interface GatewayControlPlane { /** Driving entry port used by the Gateway UI presentation adapters. */ export interface GatewayOperations { + changeGatewayAccessRole( + gatewayId: string, + userId: string, + role: GatewayAccessRole, + signal?: AbortSignal, + ): Promise; createOpenShellGatewayServiceAccount( gatewayId: string, input: OpenShellGatewayServiceAccountCreateInput, signal?: AbortSignal, ): Promise; + grantGatewayAccess( + gatewayId: string, + input: GatewayAccessGrantInput, + signal?: AbortSignal, + ): Promise; + listGatewayAccess( + gatewayId: string, + request: GatewayAccessListRequest, + signal?: AbortSignal, + ): Promise; + revokeGatewayAccess( + gatewayId: string, + userId: string, + signal?: AbortSignal, + ): Promise; + searchGatewayDirectory( + gatewayId: string, + search: string, + signal?: AbortSignal, + ): Promise; deleteOpenShellGatewayServiceAccount( gatewayId: string, serviceAccountId: string, diff --git a/packages/gateway-management-ui/src/gateway-ui-provider.test.tsx b/packages/gateway-management-ui/src/gateway-ui-provider.test.tsx index f9390c37a..77e9eae8d 100644 --- a/packages/gateway-management-ui/src/gateway-ui-provider.test.tsx +++ b/packages/gateway-management-ui/src/gateway-ui-provider.test.tsx @@ -9,7 +9,12 @@ import { } from "./gateway-ui-provider"; const gatewayOperations = { + changeGatewayAccessRole: vi.fn(), createOpenShellGatewayServiceAccount: vi.fn(), + grantGatewayAccess: vi.fn(), + listGatewayAccess: vi.fn(), + revokeGatewayAccess: vi.fn(), + searchGatewayDirectory: vi.fn(), deleteOpenShellGatewayServiceAccount: vi.fn(), findGatewayPlacements: vi.fn(), getGateway: vi.fn(), diff --git a/packages/gateway-management-ui/src/gateways/gateway-create.test.tsx b/packages/gateway-management-ui/src/gateways/gateway-create.test.tsx index d7d7eac52..9ca96562f 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-create.test.tsx +++ b/packages/gateway-management-ui/src/gateways/gateway-create.test.tsx @@ -15,7 +15,12 @@ const { createGatewayMock, findGatewayPlacementsMock, navigateMock } = })); const gatewayOperations = { + changeGatewayAccessRole: vi.fn(), createOpenShellGatewayServiceAccount: vi.fn(), + grantGatewayAccess: vi.fn(), + listGatewayAccess: vi.fn(), + revokeGatewayAccess: vi.fn(), + searchGatewayDirectory: vi.fn(), deleteOpenShellGatewayServiceAccount: vi.fn(), findGatewayPlacements: findGatewayPlacementsMock, getGateway: vi.fn(), diff --git a/packages/gateway-management-ui/src/index.ts b/packages/gateway-management-ui/src/index.ts index 2dd254689..f08cbcaaa 100644 --- a/packages/gateway-management-ui/src/index.ts +++ b/packages/gateway-management-ui/src/index.ts @@ -5,7 +5,15 @@ export { type GatewayUiNavigation, } from "./gateway-ui-provider"; export type { + GatewayAccessCapabilities, + GatewayAccessGrantInput, + GatewayAccessGrantRecord, + GatewayAccessListRequest, + GatewayAccessPage, + GatewayAccessRole, + GatewayAccessSortField, GatewayControlPlane, + GatewayDirectoryUser, GatewayFailureCode, GatewayFailureKind, GatewayInvocationContext, @@ -36,8 +44,10 @@ export type { OpenShellGatewayServiceAccountStatus, } from "./application/gateway-types"; export { + defaultGatewayAccessListRequest, defaultOpenShellGatewayServiceAccountListRequest, defaultGatewayListRequest, + gatewayAccessPageSizes, gatewayListPageSizes, GatewayOperationError, openShellGatewayServiceAccountPageSizes, @@ -89,6 +99,7 @@ export { buildOpenShellServiceAccountScript, serviceAccountGatewayAlias, } from "./service-accounts/service-account-commands"; +export { AccessPage, type AccessPageProps } from "./access/access-page"; export { ServiceAccountsPage, type ServiceAccountsPageProps, diff --git a/packages/gateway-management-ui/src/messages.ts b/packages/gateway-management-ui/src/messages.ts index ddfb74339..8e6f93f79 100644 --- a/packages/gateway-management-ui/src/messages.ts +++ b/packages/gateway-management-ui/src/messages.ts @@ -182,7 +182,8 @@ export const messages = defineMessages({ }, connectionTabsLabel: { id: "app.gateway.connection.tabsLabel", - defaultMessage: "Gateway connection, service accounts, and details", + defaultMessage: + "Gateway connection, manage access, service accounts, and details", description: "Accessible label for the gateway detail tabs.", }, connectionWaitingForProvisioning: { @@ -1080,4 +1081,235 @@ export const messages = defineMessages({ description: "Tooltip on the disabled console button once the console failed to become available within the expected time.", }, + /* eslint-disable sort-keys -- Keep the Manage access tab strings together so localization review can audit the complete surface as one unit. */ + manageAccessTab: { + id: "app.gateway.access.tab", + defaultMessage: "Manage access", + description: "Label for the gateway detail Manage access tab.", + }, + accessHeading: { + id: "app.gateway.access.heading", + defaultMessage: "Gateway access", + description: "Heading for the Manage access tab.", + }, + accessDescription: { + id: "app.gateway.access.description", + defaultMessage: "Manage who can access this gateway and at what role.", + description: "Description under the Manage access heading.", + }, + accessUserName: { + id: "app.gateway.access.column.userName", + defaultMessage: "User name", + description: "Access table column for the user's display name.", + }, + accessUserId: { + id: "app.gateway.access.column.userId", + defaultMessage: "User ID", + description: "Access table column for the username.", + }, + accessRoleColumn: { + id: "app.gateway.access.column.role", + defaultMessage: "Role", + description: "Access table column for the role.", + }, + accessRoleOwner: { + id: "app.gateway.access.role.owner", + defaultMessage: "Owner", + description: "Owner role label.", + }, + accessRoleAdmin: { + id: "app.gateway.access.role.admin", + defaultMessage: "Admin", + description: "Admin role label.", + }, + accessRoleUser: { + id: "app.gateway.access.role.user", + defaultMessage: "User", + description: "User role label.", + }, + accessRoleOwnerDescription: { + id: "app.gateway.access.role.owner.description", + defaultMessage: + "Full control, including deleting the gateway and assigning other owners.", + description: "Owner role description in the role picker.", + }, + accessRoleAdminDescription: { + id: "app.gateway.access.role.admin.description", + defaultMessage: "Manage the gateway and grant admin or user access.", + description: "Admin role description in the role picker.", + }, + accessRoleUserDescription: { + id: "app.gateway.access.role.user.description", + defaultMessage: "Read-only access to the gateway.", + description: "User role description in the role picker.", + }, + accessCreatorMarker: { + id: "app.gateway.access.creatorMarker", + defaultMessage: "Creator", + description: "Marker shown on the row of the gateway creator.", + }, + accessFindPeople: { + id: "app.gateway.access.findPeople", + defaultMessage: "Find people...", + description: "Placeholder and label for the access list search input.", + }, + accessRoleFilterLabel: { + id: "app.gateway.access.roleFilter.label", + defaultMessage: "Filter by role", + description: "Accessible label for the role filter.", + }, + accessRoleFilterAll: { + id: "app.gateway.access.roleFilter.all", + defaultMessage: "All roles", + description: "Role filter option that clears the role filter.", + }, + accessAddUsers: { + id: "app.gateway.access.addUsers", + defaultMessage: "Add users", + description: "Button that opens the directory picker.", + }, + accessDirectorySearchLabel: { + id: "app.gateway.access.directory.searchLabel", + defaultMessage: "Search the identity directory", + description: "Accessible label for the directory typeahead.", + }, + accessDirectoryLoading: { + id: "app.gateway.access.directory.loading", + defaultMessage: "Searching directory...", + description: "Loading sentinel for the directory typeahead.", + }, + accessDirectoryNoResults: { + id: "app.gateway.access.directory.noResults", + defaultMessage: "No matching users", + description: "No-results sentinel for the directory typeahead.", + }, + accessDirectoryError: { + id: "app.gateway.access.directory.error", + defaultMessage: "The directory could not be searched", + description: "Error title for the directory typeahead.", + }, + accessDirectoryClear: { + id: "app.gateway.access.directory.clear", + defaultMessage: "Clear directory search", + description: "Accessible label for clearing the directory search.", + }, + accessSelectRole: { + id: "app.gateway.access.selectRole", + defaultMessage: "Select a role", + description: "Label for the role radio group in the grant modal.", + }, + accessGrant: { + id: "app.gateway.access.grant", + defaultMessage: "Grant access", + description: "Confirm button for granting access.", + }, + accessGranting: { + id: "app.gateway.access.granting", + defaultMessage: "Granting access...", + description: "Spinner label while a grant is in flight.", + }, + accessChangeRoleLabel: { + id: "app.gateway.access.changeRole.label", + defaultMessage: "Change role", + description: "Accessible label for the inline role control.", + }, + accessRemove: { + id: "app.gateway.access.remove", + defaultMessage: "Remove access", + description: "Action that revokes a user's access.", + }, + accessRemoveTitle: { + id: "app.gateway.access.remove.title", + defaultMessage: "Remove access for {userName}?", + description: "Title of the remove-access confirmation.", + }, + accessRemoveBody: { + id: "app.gateway.access.remove.body", + defaultMessage: "This revokes the user's access to the gateway.", + description: "Body of the remove-access confirmation.", + }, + accessRemoving: { + id: "app.gateway.access.removing", + defaultMessage: "Removing access...", + description: "Spinner label while a removal is in flight.", + }, + accessEmptyTitle: { + id: "app.gateway.access.empty.title", + defaultMessage: "No access grants", + description: "Empty-state title for the access list.", + }, + accessEmptyBody: { + id: "app.gateway.access.empty.body", + defaultMessage: "No users have access to this gateway yet.", + description: "Empty-state body for the access list.", + }, + accessNoResultsTitle: { + id: "app.gateway.access.noResults.title", + defaultMessage: "No matching users", + description: "No-results title for the access list.", + }, + accessNoResultsBody: { + id: "app.gateway.access.noResults.body", + defaultMessage: "No access grants match your filters.", + description: "No-results body for the access list.", + }, + accessLoadError: { + id: "app.gateway.access.loadError", + defaultMessage: "Access could not be loaded", + description: "Error title when the access list fails to load.", + }, + accessLoadErrorBody: { + id: "app.gateway.access.loadError.body", + defaultMessage: "Try refreshing the list.", + description: "Error body when the access list fails to load.", + }, + accessLoading: { + id: "app.gateway.access.loading", + defaultMessage: "Loading access...", + description: "Spinner label while the access list loads.", + }, + accessRefresh: { + id: "app.gateway.access.refresh", + defaultMessage: "Refresh access list", + description: "Accessible label for the access list refresh button.", + }, + accessLastOwnerDisabled: { + id: "app.gateway.access.lastOwnerDisabled", + defaultMessage: "A gateway must keep at least one owner.", + description: + "Accessible reason shown when the last owner's controls are disabled.", + }, + accessOwnerOnlyDisabled: { + id: "app.gateway.access.ownerOnlyDisabled", + defaultMessage: "Only owners can change or remove an owner.", + description: + "Accessible reason shown when a non-owner admin cannot act on an owner row.", + }, + accessActionErrorLastOwner: { + id: "app.gateway.access.actionError.lastOwner", + defaultMessage: + "A gateway must keep at least one owner. Assign another owner before demoting or removing this one.", + description: "Error shown when the server rejects a last-owner mutation.", + }, + accessActionErrorDenied: { + id: "app.gateway.access.actionError.denied", + defaultMessage: "You are not allowed to perform this action.", + description: "Error shown when the server denies an access mutation.", + }, + accessActionErrorGeneric: { + id: "app.gateway.access.actionError.generic", + defaultMessage: "The action could not be completed. Try again.", + description: "Generic error shown when an access mutation fails.", + }, + accessGrantErrorNotFound: { + id: "app.gateway.access.grantError.notFound", + defaultMessage: "That user was not found in the identity directory.", + description: "Error shown when a grant target is absent from the realm.", + }, + accessViewerReadOnly: { + id: "app.gateway.access.viewerReadOnly", + defaultMessage: "You have read-only access to this list.", + description: "Notice shown to viewers who cannot manage access.", + }, + /* eslint-enable sort-keys */ }); diff --git a/packages/gateway-management-ui/src/pages/gateway-pages.test.tsx b/packages/gateway-management-ui/src/pages/gateway-pages.test.tsx index c1a28a263..2b2d7252b 100644 --- a/packages/gateway-management-ui/src/pages/gateway-pages.test.tsx +++ b/packages/gateway-management-ui/src/pages/gateway-pages.test.tsx @@ -49,7 +49,12 @@ const { })); const gatewayOperations = { + changeGatewayAccessRole: vi.fn(), createOpenShellGatewayServiceAccount: vi.fn(), + grantGatewayAccess: vi.fn(), + listGatewayAccess: vi.fn(), + revokeGatewayAccess: vi.fn(), + searchGatewayDirectory: vi.fn(), deleteOpenShellGatewayServiceAccount: vi.fn(), findGatewayPlacements: vi.fn(), getGateway: vi.fn(), diff --git a/packages/gateway-management-ui/src/pages/gateway-pages.tsx b/packages/gateway-management-ui/src/pages/gateway-pages.tsx index c28ea2894..b991ed662 100644 --- a/packages/gateway-management-ui/src/pages/gateway-pages.tsx +++ b/packages/gateway-management-ui/src/pages/gateway-pages.tsx @@ -52,6 +52,7 @@ import { import { GatewayLoadState } from "../gateways/gateway-load-state"; import { GatewayRowActions } from "../gateways/gateway-row-actions"; import { GatewayStatus } from "../gateways/gateway-status"; +import { AccessPage } from "../access/access-page"; import { ResourceTable, type ResourceTableColumn, @@ -506,10 +507,12 @@ export function GatewaysPage({ ); } -export type GatewayDetailTab = "connection" | "details" | "service-accounts"; +export type GatewayDetailTab = + "connection" | "details" | "manage-access" | "service-accounts"; const gatewayDetailTabs: readonly GatewayDetailTab[] = [ "connection", + "manage-access", "service-accounts", "details", ]; @@ -696,6 +699,20 @@ export function GatewayPage({ } /> + + + + } + > + + ({ })); const operations: GatewayOperations = { + changeGatewayAccessRole: vi.fn(), createOpenShellGatewayServiceAccount: mocks.create, + grantGatewayAccess: vi.fn(), + listGatewayAccess: vi.fn(), + revokeGatewayAccess: vi.fn(), + searchGatewayDirectory: vi.fn(), deleteOpenShellGatewayServiceAccount: mocks.delete, findGatewayPlacements: vi.fn(), getGateway: vi.fn(), diff --git a/scripts/sdk-generator/model.go b/scripts/sdk-generator/model.go index 853f29c8f..ea59423e4 100644 --- a/scripts/sdk-generator/model.go +++ b/scripts/sdk-generator/model.go @@ -32,6 +32,17 @@ type Resource struct { HasPatch bool HasStatusPatch bool Actions []string + + // Scoped-resource capabilities (data-driven from the OpenAPI spec). + HasGet bool // item GET exists + HasRevoke bool // item ".../revoke" POST exists + PatchRequestType string // scoped item PATCH request schema + PatchResponseType string // scoped item PATCH response schema + HasDirectorySearch bool // a "/directory" GET exists + DirectoryListType string // directory search response schema + GoDirectoryPath string // Go path expression for the directory sub-collection + TSDirectoryPath string // TS path expression for the directory sub-collection + TSImports []string // deduped, ordered type names the TS scoped client imports } type PathParameter struct { diff --git a/scripts/sdk-generator/parser.go b/scripts/sdk-generator/parser.go index b253889a8..bc7c584c1 100644 --- a/scripts/sdk-generator/parser.go +++ b/scripts/sdk-generator/parser.go @@ -63,25 +63,48 @@ func projectScopedResource(document *ir.Document, collection *ir.ResourceView, a return Resource{}, fmt.Errorf("list schema %s has no named items schema", listType) } + // The item path is /{param}; it may support GET, PATCH, and/or + // DELETE. GET is optional (e.g. the access facade has PATCH+DELETE, no GET). + itemPrefix := strings.TrimSuffix(collection.Path, "/") + "/" itemPath := "" - var getOperation *ir.Operation for _, operation := range document.Operations { - if operation.Method != "GET" || !strings.HasPrefix(operation.Path, strings.TrimSuffix(collection.Path, "/")+"/{") { + if !strings.HasPrefix(operation.Path, itemPrefix) { continue } - if strings.Count(strings.TrimPrefix(operation.Path, collection.Path), "/") != 1 { + remainder := strings.TrimPrefix(operation.Path, itemPrefix) + if strings.Contains(remainder, "/") || !strings.HasPrefix(remainder, "{") { continue } itemPath = operation.Path - getOperation = operation break } - if getOperation == nil { - return Resource{}, fmt.Errorf("scoped collection has no item GET operation") + if itemPath == "" { + return Resource{}, fmt.Errorf("scoped collection %s has no item operation", collection.Path) } - getResponseType := successSchemaName(document, getOperation, "200") - if getResponseType == "" { - return Resource{}, fmt.Errorf("item GET requires a named response schema") + + getResponseType := "" + if getOperation := operationAt(document, itemPath, "GET"); getOperation != nil { + getResponseType = successSchemaName(document, getOperation, "200") + if getResponseType == "" { + return Resource{}, fmt.Errorf("item GET requires a named response schema") + } + } + + patchRequestType := "" + patchResponseType := "" + if patchOperation := operationAt(document, itemPath, "PATCH"); patchOperation != nil { + patchRequestType = requestSchemaName(document, patchOperation) + patchResponseType = successSchemaName(document, patchOperation, "200") + if patchRequestType == "" || patchResponseType == "" { + return Resource{}, fmt.Errorf("item PATCH requires named request and response schemas") + } + } + + // Optional directory sub-collection search: a "/directory" GET. + directoryPath := strings.TrimSuffix(collection.Path, "/") + "/directory" + directoryListType := "" + if directoryOperation := operationAt(document, directoryPath, "GET"); directoryOperation != nil { + directoryListType = successSchemaName(document, directoryOperation, "200") } name := strings.TrimSuffix(itemType, "ListItem") @@ -96,7 +119,7 @@ func projectScopedResource(document *ir.Document, collection *ir.ResourceView, a itemParameterName := lastPathParameter(itemPath) itemParameter := newPathParameter(itemParameterName) - roots := []string{listType, itemType, createRequestType, createResponseType, getResponseType} + roots := []string{listType, itemType, createRequestType, createResponseType, getResponseType, patchRequestType, patchResponseType, directoryListType} models := projectModels(document, roots) listParameters := make([]Field, 0) for _, parameter := range listOperation.Parameters { @@ -113,7 +136,29 @@ func projectScopedResource(document *ir.Document, collection *ir.ResourceView, a collectionRelative := relativeAPIPath(collection.Path, apiPrefix) itemRelative := relativeAPIPath(itemPath, apiPrefix) - return Resource{ + directoryRelative := relativeAPIPath(directoryPath, apiPrefix) + + hasGet := getResponseType != "" + hasRevoke := operationAt(document, itemPath+"/revoke", "POST") != nil + hasPatch := patchRequestType != "" + hasDirectorySearch := directoryListType != "" + + // Ordered, deduped type names the TS scoped client imports. The order + // preserves the historical layout so unchanged resources regenerate + // byte-identically. + tsImports := dedupeStrings([]string{ + createRequestType, + createResponseType, + optional(hasGet, getResponseType), + itemType, + listType, + optional(hasPatch, patchRequestType), + optional(hasPatch, patchResponseType), + optional(hasDirectorySearch, directoryListType), + name + "ListOptions", + }) + + resource := Resource{ Name: name, Plural: resourcePlural(name), PathSegment: lastLiteralSegment(collection.Path), @@ -132,8 +177,43 @@ func projectScopedResource(document *ir.Document, collection *ir.ResourceView, a Models: models, ListParameters: listParameters, HasDelete: operationAt(document, itemPath, "DELETE") != nil, - Actions: []string{"revoke"}, - }, nil + HasGet: hasGet, + HasRevoke: hasRevoke, + HasPatch: hasPatch, + PatchRequestType: patchRequestType, + PatchResponseType: patchResponseType, + HasDirectorySearch: hasDirectorySearch, + DirectoryListType: directoryListType, + TSImports: tsImports, + } + if hasDirectorySearch { + resource.GoDirectoryPath = goPathExpression(directoryRelative, scopeParameters, nil) + resource.TSDirectoryPath = tsPathExpression(directoryRelative, scopeParameters, nil) + } + if hasRevoke { + resource.Actions = []string{"revoke"} + } + return resource, nil +} + +func optional(include bool, value string) string { + if include { + return value + } + return "" +} + +func dedupeStrings(values []string) []string { + seen := make(map[string]bool, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + if value == "" || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out } func scopedCollectionViews(document *ir.Document, apiPrefix string) []*ir.ResourceView { @@ -147,6 +227,12 @@ func scopedCollectionViews(document *ir.Document, apiPrefix string) []*ir.Resour if remainder == view.Path || remainder == "" || !strings.Contains(remainder, "/") || seen[view.Path] { continue } + // A nested collection that cannot be POSTed to (e.g. a ".../directory" + // search sub-collection) is not a resource in its own right; it is + // projected as a method on its parent resource instead. + if operationAt(document, view.Path, "POST") == nil { + continue + } seen[view.Path] = true result = append(result, view) } diff --git a/scripts/sdk-generator/templates/go/scoped_client.go.tmpl b/scripts/sdk-generator/templates/go/scoped_client.go.tmpl index eee20f730..4c50d3b02 100644 --- a/scripts/sdk-generator/templates/go/scoped_client.go.tmpl +++ b/scripts/sdk-generator/templates/go/scoped_client.go.tmpl @@ -35,6 +35,7 @@ func (a *{{.Resource.Name}}API) Create(ctx context.Context{{range .Resource.Scop return &result, nil } +{{if .Resource.HasGet}} func (a *{{.Resource.Name}}API) Get(ctx context.Context{{range .Resource.ScopeParameters}}, {{.GoName}} string{{end}}, {{.Resource.ItemParameter.GoName}} string) (*types.{{.Resource.GetResponseType}}, error) { var result types.{{.Resource.GetResponseType}} if _, err := a.client.doAny(ctx, http.MethodGet, {{.Resource.GoItemPath}}, nil, &result, http.StatusOK); err != nil { @@ -42,7 +43,7 @@ func (a *{{.Resource.Name}}API) Get(ctx context.Context{{range .Resource.ScopePa } return &result, nil } - +{{end}} func (a *{{.Resource.Name}}API) List(ctx context.Context{{range .Resource.ScopeParameters}}, {{.GoName}} string{{end}}, opts *types.{{.Resource.Name}}ListOptions) (*types.{{.Resource.ListType}}, error) { path := {{.Resource.GoCollectionPath}} if opts != nil { @@ -63,6 +64,7 @@ func (a *{{.Resource.Name}}API) List(ctx context.Context{{range .Resource.ScopeP return &result, nil } +{{if .Resource.HasRevoke}} func (a *{{.Resource.Name}}API) Revoke(ctx context.Context{{range .Resource.ScopeParameters}}, {{.GoName}} string{{end}}, {{.Resource.ItemParameter.GoName}} string) (*types.{{.Resource.ItemType}}, error) { var result types.{{.Resource.ItemType}} if _, err := a.client.doAny(ctx, http.MethodPost, {{.Resource.GoItemPath}}+"/revoke", nil, &result, http.StatusOK, http.StatusAccepted); err != nil { @@ -70,7 +72,7 @@ func (a *{{.Resource.Name}}API) Revoke(ctx context.Context{{range .Resource.Scop } return &result, nil } - +{{end}} func (a *{{.Resource.Name}}API) Delete(ctx context.Context{{range .Resource.ScopeParameters}}, {{.GoName}} string{{end}}, {{.Resource.ItemParameter.GoName}} string) (*types.{{.Resource.ItemType}}, error) { var result types.{{.Resource.ItemType}} status, err := a.client.doAny(ctx, http.MethodDelete, {{.Resource.GoItemPath}}, nil, &result, http.StatusNoContent, http.StatusAccepted) @@ -82,7 +84,34 @@ func (a *{{.Resource.Name}}API) Delete(ctx context.Context{{range .Resource.Scop } return &result, nil } - +{{if .Resource.HasPatch}} +func (a *{{.Resource.Name}}API) Update(ctx context.Context{{range .Resource.ScopeParameters}}, {{.GoName}} string{{end}}, {{.Resource.ItemParameter.GoName}} string, request *types.{{.Resource.PatchRequestType}}) (*types.{{.Resource.PatchResponseType}}, error) { + body, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("marshal {{.Resource.Name | snakeCase}} update request: %w", err) + } + var result types.{{.Resource.PatchResponseType}} + if _, err := a.client.doAny(ctx, http.MethodPatch, {{.Resource.GoItemPath}}, body, &result, http.StatusOK); err != nil { + return nil, err + } + return &result, nil +} +{{end}} +{{- if .Resource.HasDirectorySearch}} +func (a *{{.Resource.Name}}API) SearchDirectory(ctx context.Context{{range .Resource.ScopeParameters}}, {{.GoName}} string{{end}}, search string) (*types.{{.Resource.DirectoryListType}}, error) { + path := {{.Resource.GoDirectoryPath}} + if search != "" { + params := url.Values{} + params.Set("search", search) + path += "?" + params.Encode() + } + var result types.{{.Resource.DirectoryListType}} + if _, err := a.client.doAny(ctx, http.MethodGet, path, nil, &result, http.StatusOK); err != nil { + return nil, err + } + return &result, nil +} +{{end}} func (a *{{.Resource.Name}}API) ListAll(ctx context.Context{{range .Resource.ScopeParameters}}, {{.GoName}} string{{end}}, opts *types.{{.Resource.Name}}ListOptions) *Iterator[types.{{.Resource.ItemType}}] { if opts == nil { opts = types.New{{.Resource.Name}}ListOptions().Build() diff --git a/scripts/sdk-generator/templates/ts/scoped_client.ts.tmpl b/scripts/sdk-generator/templates/ts/scoped_client.ts.tmpl index c8622ce22..a6ff5b8fb 100644 --- a/scripts/sdk-generator/templates/ts/scoped_client.ts.tmpl +++ b/scripts/sdk-generator/templates/ts/scoped_client.ts.tmpl @@ -6,12 +6,9 @@ import type { SDKClientConfig, RequestOptions } from './base.js'; import { sdkFetch } from './base.js'; import type { - {{.Resource.CreateRequestType}}, - {{.Resource.CreateResponseType}}, - {{.Resource.GetResponseType}}, - {{.Resource.ItemType}}, - {{.Resource.ListType}}, - {{.Resource.Name}}ListOptions, +{{- range .Resource.TSImports}} + {{.}}, +{{- end}} } from './{{.Resource.Name | snakeCase}}.js'; export class {{.Resource.Name}}API { @@ -20,10 +17,12 @@ export class {{.Resource.Name}}API { async create({{range .Resource.ScopeParameters}}{{.TSName}}: string, {{end}}data: {{.Resource.CreateRequestType}}, opts?: RequestOptions): Promise<{{.Resource.CreateResponseType}}> { return sdkFetch<{{.Resource.CreateResponseType}}>(this.config, 'POST', {{.Resource.TSCollectionPath}}, data, opts); } +{{- if .Resource.HasGet}} async get({{range .Resource.ScopeParameters}}{{.TSName}}: string, {{end}}{{.Resource.ItemParameter.TSName}}: string, opts?: RequestOptions): Promise<{{.Resource.GetResponseType}}> { return sdkFetch<{{.Resource.GetResponseType}}>(this.config, 'GET', {{.Resource.TSItemPath}}, undefined, opts); } +{{- end}} async list({{range .Resource.ScopeParameters}}{{.TSName}}: string, {{end}}listOpts?: {{.Resource.Name}}ListOptions, opts?: RequestOptions): Promise<{{.Resource.ListType}}> { const params = new URLSearchParams(); @@ -36,14 +35,34 @@ export class {{.Resource.Name}}API { const path = {{.Resource.TSCollectionPath}} + (query ? `?${query}` : ''); return sdkFetch<{{.Resource.ListType}}>(this.config, 'GET', path, undefined, opts); } +{{- if .Resource.HasRevoke}} async revoke({{range .Resource.ScopeParameters}}{{.TSName}}: string, {{end}}{{.Resource.ItemParameter.TSName}}: string, opts?: RequestOptions): Promise<{{.Resource.ItemType}}> { return sdkFetch<{{.Resource.ItemType}}>(this.config, 'POST', {{.Resource.TSItemPath}} + '/revoke', undefined, opts); } +{{- end}} async delete({{range .Resource.ScopeParameters}}{{.TSName}}: string, {{end}}{{.Resource.ItemParameter.TSName}}: string, opts?: RequestOptions): Promise<{{.Resource.ItemType}} | undefined> { return sdkFetch<{{.Resource.ItemType}} | undefined>(this.config, 'DELETE', {{.Resource.TSItemPath}}, undefined, opts); } +{{- if .Resource.HasPatch}} + + async update({{range .Resource.ScopeParameters}}{{.TSName}}: string, {{end}}{{.Resource.ItemParameter.TSName}}: string, data: {{.Resource.PatchRequestType}}, opts?: RequestOptions): Promise<{{.Resource.PatchResponseType}}> { + return sdkFetch<{{.Resource.PatchResponseType}}>(this.config, 'PATCH', {{.Resource.TSItemPath}}, data, opts); + } +{{- end}} +{{- if .Resource.HasDirectorySearch}} + + async searchDirectory({{range .Resource.ScopeParameters}}{{.TSName}}: string, {{end}}search?: string, opts?: RequestOptions): Promise<{{.Resource.DirectoryListType}}> { + const params = new URLSearchParams(); + if (search !== undefined && search !== '') { + params.set('search', search); + } + const query = params.toString(); + const path = {{.Resource.TSDirectoryPath}} + (query ? `?${query}` : ''); + return sdkFetch<{{.Resource.DirectoryListType}}>(this.config, 'GET', path, undefined, opts); + } +{{- end}} async *listAll({{range .Resource.ScopeParameters}}{{.TSName}}: string, {{end}}size: number = 20, opts?: RequestOptions): AsyncGenerator<{{.Resource.ItemType}}> { let page = 1; diff --git a/skills/RECONCILE.md b/skills/RECONCILE.md index 3b03c5dab..4f475d5db 100644 --- a/skills/RECONCILE.md +++ b/skills/RECONCILE.md @@ -49,7 +49,7 @@ skills/ ## Reconciliation State -**Last analyzed**: 2026-10-01 (scoped reconciliation of specs/platform/e2e-testing.spec.md + specs/platform/e2e-console-browser-testing.spec.md against HEAD `addb9509` after #413 renumbered the core e2e suite 6 -> 14 areas. All new gaps were test-only (underlying API/CP/UI features already existed). **Executed waves E2E-W4 + CON-W2:** added e2e areas 12-14 to `tests/e2e/e2e-openshell.sh` (E2E-14 registration, E2E-16 reconnect+gRPC identity, E2E-17 promotion, E2E-18 reconciled status, E2E-19 admin inventory+phase) plus helpers in `lib.sh`/`drivers/kind.sh`, and console areas 7+11 (CON-E2E-14 typeahead+install-docs, CON-E2E-13 fleet-dashboard promotion). Verified on a local Kind cluster: full long run = **90 passed, 2 failed** (1 pre-existing area-9 developer-sandbox flake unrelated to this work; 1 promotion `Degraded` assertion since corrected). Short mode 20/0; console unit test 56/0; all shell unit tests pass; `bash -n` clean. **Two divergences recorded** (do not edit specs): D-E2E-DELETE-CP (gateway DELETE needs the CP, so "delete while disconnected" is infeasible -- E2E-16 covers create-convergence only) and D-E2E-DEGRADED (a failed rollout keeps the last-good workload serving and stays Running, not Degraded; the test asserts the never-moves-to-bad safety invariant). Browser areas (CON-E2E-13/14) are code-complete + unit-tested but not browser-run locally (agent-browser not installed); CI covers them. Deferred: E2E-15 multi-cluster (E2E-W5, needs second-CP deploy) and E2E-20 qualification (E2E-W6, infra-gated).) Also 2026-09-23 (HYPERSHELL-334 implementation rebased onto `b152182c`: standing CI secrets moved to AWS Secrets Manager + GitHub OIDC + ESO; test-tier principals seeded from ESO on CI-owned PR environments and kept until the environment is destroyed). Also 2026-09-23 (scoped reanalysis of specs/platform/ephemeral-pr-environments.spec.md after #342: PR-ENV-6 now also publishes the hypershell-realm Keycloak admin console URL with impersonation instructions; PR-ENV-8 hides the password form when the GitHub IdP is active; PR-ENV-9 adds the interactive admin console path via `view-users`/`query-users`/`impersonation` realm-management roles granted on first broker login - all implemented and Present). Also 2026-09-22 (scoped reanalysis + execution of specs/platform/e2e-console-browser-testing.spec.md: CON-W1 fully closed; 49/49 checks pass on Kind including sandbox create/delete via the PatternFly ActionsColumn row kebab + typed-name confirmation modal and TSL single-quote quoting fix in lib.sh; CI integration wired into `.github/workflows/e2e.yml`; spec added to coverage table as 100%). Prior 2026-09-22 (scoped reanalysis for commits since `464ec5e`: updated codebase commit to `5e14f29b` (external-db-only-redo HEAD: remove ManagedDatabase API); registered 6 new spec files (gateway-provision-outcomes, gateway-release-distribution, gateway-fleet-total-trend, gateway-sandbox-active-trends, hub-cluster-utilization-trends, openshell-branch-build); updated E2E-9 for 3-mode E2E_MODE split (#332); added E2E-13 for macOS CLI container wrapper (#335); added DM-3g/h/i for new Gateway schema fields; added OS-14/OS-15 for openshift-seed and openshift-test requirements; full-corpus recount pending for new specs). Prior 2026-09-16 (scoped reanalysis + execution of specs/platform/ephemeral-pr-environments.spec.md for HYPERSHELL-240 after the spec moved from continuous-deploy-for-life-of-PR to ephemeral-by-default with `/pr-extend` / `/pr-destroy`; D-E2E-OIDC closed as Present via PR-ENV-10; the last full-corpus analysis remains 2026-08-31). Prior 2026-09-14 scoped analysis of specs/platform/gateway-deletion-finalization.spec.md for HYPERSHELL-182; closed the no-silent-orphan gap G1: best-effort deletion failures for gateway-owned resources with no automatic recovery path -- leaked ClusterRoleBinding, leaked Keycloak gateway/console clients, and Keycloak clients skipped when the stored identity is unresolvable or the provisioner is deconfigured -- now emit a durable IncompleteFinalization Warning Event in the control-plane namespace instead of only logging; in-namespace sweep G2 already satisfied. Prior 2026-09-09 scoped reanalysis of e2e-testing.spec.md + local-development.spec.md against HEAD `21f02a0` for the new OpenShift E2E CI content added by the HYPERSHELL-240 docs commit: OpenShift driver unification #232/#244, dynamic namespace-GC timing, and the merge-queue Kind CI gate are all implemented; D-E2E-OIDC was then still listed as a divergence pending HYPERSHELL-240 (closed 2026-09-16). Prior 2026-09-04 scoped reanalysis of the CP-OBS-07 reconcile-queue metric changes after review; operational-dashboard through OP-DASH-20; OP-DASH-18 NaN fallback; OP-DASH-19 independent metric sources + partial failure; OP-DASH-20 section titles + header refresh consolidation; cluster memory/cpu/pods/nodes metrics; gateway-provision-time GPT-W1; registered-users complete; the last full-corpus analysis remains 2026-08-31) +**Last analyzed**: 2026-10-06 (scoped reconciliation of the `user_management` PR's new specs: `specs/platform/gateway-access-management.spec.md` (GAM-01..14) and `specs/web-console/gateway-access-management.spec.md` (GAM-UI-01..11), plus the aligned edits in `data-model`, `security/rbac-enforcement`, `openshell-gateway-keycloak`, `web-console/architecture`, and `web-console/user_flows.md`. Executed waves GAM-W1..W6: (W1 API) new `gatewayAccess` facade plugin over the existing `role_bindings` storage -- enriched list, grant with Keycloak-realm resolution + user pre-provisioning, in-place `role_id` change (single `UPDATED` event), revoke, last-owner `409`, owner/admin-tier authorization, idempotent convergence; new `gateway:admin` built-in role seeded idempotently; RBAC middleware extended for the `access` resource + `gateway:admin` (read/update, never delete); OpenAPI `openapi.gatewayAccess.yaml` + 5 endpoints; 15 service-level integration tests pass. (W-CP) OIDC Role Bridge: `gateway:admin` -> `openshell-admin`+`openshell-user`, and ADDED/UPDATED is now a true union reconcile (demotion via in-place UPDATE strips `openshell-admin`, keeps `openshell-user`); reconciler tests pass. (W-CLI) `hsctl {create,list,update,delete} gatewayAccess` (new `update` verb) via raw-HTTP pkg mirroring service accounts; 403/409 surface as non-zero exits; pkg tests pass. (W-SDK) extended the SDK generator for the scoped access resource (optional item-GET, PATCH, directory sub-collection) and regenerated the Go + TS clients -- service_accounts output stays byte-identical. (W-DIR) Keycloak directory projection: `keycloak.ListRealmUsers`, a CP `internal/directory` projection + refresh loop, and a directory gRPC service riding the existing provisioner channel the API server already dials; the API facade resolver reads it (GAM-09 + GAM-04 realm resolution). (W-FE/W-WC) web-console Manage access tab in the GMUI package (ports, probes, table, directory picker, role modal, last-owner UI, 221 tests) plus the WC production adapter + contract tests; GMUI `check` and web-console `check` both green. All 14 platform + 11 UI requirements are now Present; GAM-UI-11 Storybook fixtures are the one deliberate omission (covered by component tests instead; no access mockup exists to extend). See the gateway-access gap table below.) Prior 2026-10-01 (scoped reconciliation of specs/platform/e2e-testing.spec.md + specs/platform/e2e-console-browser-testing.spec.md against HEAD `addb9509` after #413 renumbered the core e2e suite 6 -> 14 areas. All new gaps were test-only (underlying API/CP/UI features already existed). **Executed waves E2E-W4 + CON-W2:** added e2e areas 12-14 to `tests/e2e/e2e-openshell.sh` (E2E-14 registration, E2E-16 reconnect+gRPC identity, E2E-17 promotion, E2E-18 reconciled status, E2E-19 admin inventory+phase) plus helpers in `lib.sh`/`drivers/kind.sh`, and console areas 7+11 (CON-E2E-14 typeahead+install-docs, CON-E2E-13 fleet-dashboard promotion). Verified on a local Kind cluster: full long run = **90 passed, 2 failed** (1 pre-existing area-9 developer-sandbox flake unrelated to this work; 1 promotion `Degraded` assertion since corrected). Short mode 20/0; console unit test 56/0; all shell unit tests pass; `bash -n` clean. **Two divergences recorded** (do not edit specs): D-E2E-DELETE-CP (gateway DELETE needs the CP, so "delete while disconnected" is infeasible -- E2E-16 covers create-convergence only) and D-E2E-DEGRADED (a failed rollout keeps the last-good workload serving and stays Running, not Degraded; the test asserts the never-moves-to-bad safety invariant). Browser areas (CON-E2E-13/14) are code-complete + unit-tested but not browser-run locally (agent-browser not installed); CI covers them. Deferred: E2E-15 multi-cluster (E2E-W5, needs second-CP deploy) and E2E-20 qualification (E2E-W6, infra-gated).) Also 2026-09-23 (HYPERSHELL-334 implementation rebased onto `b152182c`: standing CI secrets moved to AWS Secrets Manager + GitHub OIDC + ESO; test-tier principals seeded from ESO on CI-owned PR environments and kept until the environment is destroyed). Also 2026-09-23 (scoped reanalysis of specs/platform/ephemeral-pr-environments.spec.md after #342: PR-ENV-6 now also publishes the hypershell-realm Keycloak admin console URL with impersonation instructions; PR-ENV-8 hides the password form when the GitHub IdP is active; PR-ENV-9 adds the interactive admin console path via `view-users`/`query-users`/`impersonation` realm-management roles granted on first broker login - all implemented and Present). Also 2026-09-22 (scoped reanalysis + execution of specs/platform/e2e-console-browser-testing.spec.md: CON-W1 fully closed; 49/49 checks pass on Kind including sandbox create/delete via the PatternFly ActionsColumn row kebab + typed-name confirmation modal and TSL single-quote quoting fix in lib.sh; CI integration wired into `.github/workflows/e2e.yml`; spec added to coverage table as 100%). Prior 2026-09-22 (scoped reanalysis for commits since `464ec5e`: updated codebase commit to `5e14f29b` (external-db-only-redo HEAD: remove ManagedDatabase API); registered 6 new spec files (gateway-provision-outcomes, gateway-release-distribution, gateway-fleet-total-trend, gateway-sandbox-active-trends, hub-cluster-utilization-trends, openshell-branch-build); updated E2E-9 for 3-mode E2E_MODE split (#332); added E2E-13 for macOS CLI container wrapper (#335); added DM-3g/h/i for new Gateway schema fields; added OS-14/OS-15 for openshift-seed and openshift-test requirements; full-corpus recount pending for new specs). Prior 2026-09-16 (scoped reanalysis + execution of specs/platform/ephemeral-pr-environments.spec.md for HYPERSHELL-240 after the spec moved from continuous-deploy-for-life-of-PR to ephemeral-by-default with `/pr-extend` / `/pr-destroy`; D-E2E-OIDC closed as Present via PR-ENV-10; the last full-corpus analysis remains 2026-08-31). Prior 2026-09-14 scoped analysis of specs/platform/gateway-deletion-finalization.spec.md for HYPERSHELL-182; closed the no-silent-orphan gap G1: best-effort deletion failures for gateway-owned resources with no automatic recovery path -- leaked ClusterRoleBinding, leaked Keycloak gateway/console clients, and Keycloak clients skipped when the stored identity is unresolvable or the provisioner is deconfigured -- now emit a durable IncompleteFinalization Warning Event in the control-plane namespace instead of only logging; in-namespace sweep G2 already satisfied. Prior 2026-09-09 scoped reanalysis of e2e-testing.spec.md + local-development.spec.md against HEAD `21f02a0` for the new OpenShift E2E CI content added by the HYPERSHELL-240 docs commit: OpenShift driver unification #232/#244, dynamic namespace-GC timing, and the merge-queue Kind CI gate are all implemented; D-E2E-OIDC was then still listed as a divergence pending HYPERSHELL-240 (closed 2026-09-16). Prior 2026-09-04 scoped reanalysis of the CP-OBS-07 reconcile-queue metric changes after review; operational-dashboard through OP-DASH-20; OP-DASH-18 NaN fallback; OP-DASH-19 independent metric sources + partial failure; OP-DASH-20 section titles + header refresh consolidation; cluster memory/cpu/pods/nodes metrics; gateway-provision-time GPT-W1; registered-users complete; the last full-corpus analysis remains 2026-08-31) **Spec corpus**: 56 spec files; the coverage table tracks 48 analyzed feature/spec groups after adding Ephemeral CI Secrets, Ephemeral Test Credentials, Console Browser Testing, Gateway Provision Outcomes, Gateway Release Distribution, Gateway Fleet Total Trend, Gateway Sandbox Active Trends, Hub Cluster Utilization Trends, and OpenShell Branch Build (9 new specs from commits `85927b3c`/`be0bf2ea`/`630a5ed1`/`2bdeece6`; full per-requirement analysis pending for the last 6) **Codebase commit**: working tree (HYPERSHELL-334 standing CI secrets + test-tier credentials, rebased onto `b152182c`; prior `5e14f29b` feat: remove ManagedDatabase API), then e2e-console-browser-testing CON-W1 (`2bdeece6`), then #342 Keycloak admin-console impersonation (`d7f33594`) @@ -120,6 +120,28 @@ Layer 7: web-console/architecture (depends on data-model, security, UI ## Gap Table +### gateway-access-management.spec.md + web-console/gateway-access-management.spec.md (user_management PR) + +| # | Requirement | Status | Code Location | Wave | +|---|-------------|--------|---------------|------| +| GAM-01 | `gateway:admin` granted-administrator role (read/update, no delete; manages Admin/User tiers) | Present | `plugins/roles/{model.go,migration.go}`, `pkg/rbac/authorization.go` (`isGatewayAuthorized` admin case) | GAM-W1 | +| GAM-02 | Keycloak Role Bridge: owner+admin -> both client roles; union reconcile on create/update/delete | Present | `control-plane/internal/reconciler/role_binding_reconciler.go` (`keycloakRoleMap`, `effectiveKcRoles`, union loop) | GAM-W-CP | +| GAM-03 | Enriched gateway access list (server-joined, search + role filter + pagination) | Present | `plugins/gatewayAccess/{dao.go,service.go,handler.go}` | GAM-W1 | +| GAM-04 | Grant access; realm resolution + user pre-provision; `404` if absent from realm | Present (CP-backed resolver wired over the provisioner gRPC channel) | `plugins/gatewayAccess/{service.go,directory.go,directory_client.go}`, `plugins/users` `UpsertByUsername` | GAM-W1 / GAM-W-DIR | +| GAM-05 | Change role in place (single `UPDATED` event, no zero-access window) | Present | `plugins/roleBindings/{dao.go,service.go}` (`SetGatewayRole`, `Update`), `plugins/gatewayAccess/service.go` | GAM-W1 | +| GAM-06 | Revoke access | Present | `plugins/roleBindings/service.go` (`RevokeGatewayAccess`), `plugins/gatewayAccess/service.go` | GAM-W1 | +| GAM-07 | Last-owner protection (`409` on demote/revoke of sole owner, descriptive message) | Present | `plugins/gatewayAccess/service.go` (`guardLastOwner`), serialized by advisory lock | GAM-W1 | +| GAM-08 | Access-management authorization (owner/admin; owner-tier gating; viewer `403`; non-member `404`) | Present | `plugins/gatewayAccess/service.go` (`authorizeManage`), `pkg/rbac/authorization.go` (`isAccessFacadeAuthorized`) | GAM-W1 | +| GAM-09 | Keycloak directory search served from a CP-maintained projection | Present | CP `internal/directory/` (projection + refresh loop + gRPC service riding the provisioner channel), `internal/keycloak` `ListRealmUsers`; `plugins/gatewayAccess/directory_client.go` reads it; API authz tested | GAM-W-DIR | +| GAM-10 | One effective role per user per gateway; idempotent re-grant; converge not accumulate | Present | `plugins/roleBindings/service.go` (`SetGatewayRole` convergence) | GAM-W1 | +| GAM-11 | Service-account role capping for admins (admin capped like owner) | Present | `pkg/rbac/authorization.go` service_accounts branch already admits gateway bindings; capping in `plugins/serviceAccounts` (verify admin tier) | GAM-W1 (verify) | +| GAM-12 | Additive, idempotent role migration | Present | `plugins/roles/migration.go` (`migrationAddGatewayAdminRole`, `builtInRoleSeeds`) | GAM-W1 | +| GAM-13 | `hsctl` CLI parity (`create/list/update/delete gatewayAccess`; new `update` verb; 403/409 non-zero exit) | Present | `components/cli/cmd/hsctl/{create,list,update,delete}/gatewayAccess`, `components/cli/pkg/gatewayaccess` | GAM-W-CLI | +| GAM-14 | Verification (API integration, CP role-bridge, CLI tests, `make generate` SDKs) | Present | `plugins/gatewayAccess/integration_test.go` (15), `control-plane/.../role_binding_reconciler_test.go`, `internal/directory/*_test.go`, `components/cli/pkg/gatewayaccess/*_test.go`; Go+TS SDK regenerated (service_accounts byte-identical) | GAM-W1/CP/CLI/SDK | +| GAM-UI-01..11 | Manage access console tab (ports, probes, table, directory picker, role modal, last-owner UI, tests) | Present | `packages/gateway-management-ui/src/access/*`, `application/*`, `pages/gateway-pages.tsx`; WC adapter `components/web-console/app/adapters/api/gateway-operations.ts` + contract tests; GMUI `check` + WC `check` green | GAM-W-FE / GAM-W-WC | + +**Direction checks:** Spec->code: the facade implements all server behaviors (list/grant/change/revoke/directory) over existing RoleBinding storage; `gateway:admin` added across roles, RBAC, and the Role Bridge. Code->spec: the access facade adds no public RoleBinding mutation surface (the generic `role_bindings` resource stays create/delete-only; the in-place `role_id` change is an internal facade/service operation). OpenAPI->spec: `openapi.gatewayAccess.yaml` declares the 5 endpoints and their `required[]` matching GAM-03/04/05/09. + ### generated-gateway-config-validation.spec.md (HYPERSHELL-179) | # | Requirement | Status | Gap | Code Location | Wave | From 1ba870d5c85fcdf90cc3f7d7d0896920b77125dd Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Tue, 6 Oct 2026 16:33:02 -0400 Subject: [PATCH 05/23] fix(gateway-access): require gateway ownership to grant gateway:admin The generic POST/DELETE /role_bindings path validated caller ownership only for gateway:owner and gateway:viewer, omitting the new gateway:admin tier. Any caller holding a single RoleBinding could self-grant gateway:admin (-> openshell-admin + openshell-user via the Role Bridge) on any gateway, bypassing the facade's owner-tier gating. Route gateway:admin through validateCallerOwnsGateway in Create/Delete and require scope=gateway in validateScopeMatchesRole, matching the facade semantics. Add a regression test for the self-grant exploit path. Co-Authored-By: Claude Opus 4.8 --- .../plugins/roleBindings/integration_test.go | 41 +++++++++++++++++++ .../plugins/roleBindings/service.go | 6 +-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/components/api-server/plugins/roleBindings/integration_test.go b/components/api-server/plugins/roleBindings/integration_test.go index d43beeaf1..6c5185e12 100644 --- a/components/api-server/plugins/roleBindings/integration_test.go +++ b/components/api-server/plugins/roleBindings/integration_test.go @@ -336,6 +336,47 @@ func TestGrantValidation_CrossGatewayEscalation(t *testing.T) { Expect(crossGWErr.HttpCode).To(Equal(http.StatusForbidden)) } +// TestGrantValidation_AdminSelfGrantRejected covers the privilege-escalation +// path where a caller holding any single binding tries to self-grant +// gateway:admin on a gateway it does not own. admin must go through the same +// owner check as owner/viewer (not slip past it). +func TestGrantValidation_AdminSelfGrantRejected(t *testing.T) { + test.RegisterIntegration(t) + + roleService := roles.Service(&environments.Environment().Services) + rbService := roleBindings.Service(&environments.Environment().Services) + userService := users.Service(&environments.Environment().Services) + + callerUser, userErr := userService.UpsertByUsername(context.Background(), "admin-escalation-caller", nil, nil) + Expect(userErr).NotTo(HaveOccurred()) + + viewerRole, _ := roleService.GetByName(context.Background(), roles.RoleGatewayViewer) + adminRole, _ := roleService.GetByName(context.Background(), roles.RoleGatewayAdmin) + + gwOwned := "gw-admin-owned" + gwTarget := "gw-admin-target" + + // Caller holds only a viewer binding on an unrelated gateway. + _, viewerErr := rbService.Create(context.Background(), &roleBindings.RoleBinding{ + RoleID: viewerRole.ID, + Scope: roleBindings.ScopeGateway, + UserID: &callerUser, + GatewayID: &gwOwned, + }) + Expect(viewerErr).NotTo(HaveOccurred()) + + callerCtx := context.WithValue(context.Background(), rbac.ContextUserIDKey, callerUser) + + _, grantErr := rbService.Create(callerCtx, &roleBindings.RoleBinding{ + RoleID: adminRole.ID, + Scope: roleBindings.ScopeGateway, + UserID: &callerUser, + GatewayID: &gwTarget, + }) + Expect(grantErr).To(HaveOccurred()) + Expect(grantErr.HttpCode).To(Equal(http.StatusForbidden)) +} + // TestUserProvisioningMiddleware_DefaultRoleAssignedWithNoJWTRoles drives the // real HTTP middleware path with a token that carries no realm_access roles and // asserts that the user receives a gateway:creator binding. This is the exact diff --git a/components/api-server/plugins/roleBindings/service.go b/components/api-server/plugins/roleBindings/service.go index 0a08924e2..1111d79f3 100644 --- a/components/api-server/plugins/roleBindings/service.go +++ b/components/api-server/plugins/roleBindings/service.go @@ -222,7 +222,7 @@ func (s *sqlRoleBindingService) Create(ctx context.Context, rb *RoleBinding) (*R return nil, err } - if role.Name == roles.RoleGatewayOwner || role.Name == roles.RoleGatewayViewer { + if role.Name == roles.RoleGatewayOwner || role.Name == roles.RoleGatewayAdmin || role.Name == roles.RoleGatewayViewer { if err := s.validateCallerOwnsGateway(ctx, rb.GatewayID); err != nil { return nil, err } @@ -265,7 +265,7 @@ func (s *sqlRoleBindingService) Delete(ctx context.Context, id string) *errors.S return errors.Validation("invalid role_id: role not found") } - if role.Name == roles.RoleGatewayOwner || role.Name == roles.RoleGatewayViewer { + if role.Name == roles.RoleGatewayOwner || role.Name == roles.RoleGatewayAdmin || role.Name == roles.RoleGatewayViewer { if err := s.validateCallerOwnsGateway(ctx, rb.GatewayID); err != nil { return err } @@ -529,7 +529,7 @@ func (s *sqlRoleBindingService) validateScopeMatchesRole(roleName string, rb *Ro if rb.Scope != ScopeGlobal { return errors.Validation("role %q requires scope=global", roleName) } - case roles.RoleGatewayOwner, roles.RoleGatewayViewer: + case roles.RoleGatewayOwner, roles.RoleGatewayAdmin, roles.RoleGatewayViewer: if rb.Scope != ScopeGateway { return errors.Validation("role %q requires scope=gateway", roleName) } From 9d00c91de8c2b36dc2cba43f76f76a9d5efafda0 Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Tue, 6 Oct 2026 16:37:06 -0400 Subject: [PATCH 06/23] fix(ci): update openapi op count for gateway-access; gofmt controller main The gateway-access feature added 5 operations (GET/POST /access, GET /access/directory, PATCH/DELETE /access/{user_id}), so the embedded OpenAPI contract now has 40 operations, not 35. Update the expected count in openapi_embed_test.go. Also gofmt control-plane cmd/hypershell-controller/main.go (import ordering) to pass the formatting check. Co-Authored-By: Claude Opus 4.8 --- components/api-server/pkg/api/openapi_embed_test.go | 4 ++-- components/control-plane/cmd/hypershell-controller/main.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/components/api-server/pkg/api/openapi_embed_test.go b/components/api-server/pkg/api/openapi_embed_test.go index 430378189..16ebb6a65 100644 --- a/components/api-server/pkg/api/openapi_embed_test.go +++ b/components/api-server/pkg/api/openapi_embed_test.go @@ -55,8 +55,8 @@ func TestGetOpenAPISpecReturnsCompleteContract(t *testing.T) { operationIDs[operation.OperationID] = method + " " + path } } - if operationCount != 35 { - t.Fatalf("embedded operation count = %d, want 35", operationCount) + if operationCount != 40 { + t.Fatalf("embedded operation count = %d, want 40", operationCount) } expectedDeletes := map[string]string{ diff --git a/components/control-plane/cmd/hypershell-controller/main.go b/components/control-plane/cmd/hypershell-controller/main.go index e96d6e878..d0abea4d0 100644 --- a/components/control-plane/cmd/hypershell-controller/main.go +++ b/components/control-plane/cmd/hypershell-controller/main.go @@ -17,8 +17,8 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" - pb "github.com/openshift-online/hypershell/components/api-server/pkg/api/grpc/hypershell/v1" provisionerpb "github.com/openshift-online/hypershell/components/api-server/pkg/api/grpc/hypershell/provisioner/v1" + pb "github.com/openshift-online/hypershell/components/api-server/pkg/api/grpc/hypershell/v1" "github.com/openshift-online/hypershell/components/control-plane/internal/auth" "github.com/openshift-online/hypershell/components/control-plane/internal/config" "github.com/openshift-online/hypershell/components/control-plane/internal/directory" From 4bf3bb1b6096bc8213187db713a5de393e59154c Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Wed, 7 Oct 2026 12:05:25 -0400 Subject: [PATCH 07/23] fix(roles): avoid duplicate migration ID after merge with main Main's ADLC change added plugins/gateways migrationDropReleaseFK with ID 2026100600000001; the gateway-access role seed used the same ID, so after the merge gormigrate failed at startup ("Duplicated migration ID"), breaking every api-server package that runs migrations (seen in CI: Unit / Go - API server). Bump the roles gateway:admin seed to 2026100600000004 (a free slot on the same date). The migration is idempotent, so re-running under the new ID is a no-op. Co-Authored-By: Claude Opus 4.8 --- components/api-server/plugins/roles/migration.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/api-server/plugins/roles/migration.go b/components/api-server/plugins/roles/migration.go index e0bd2261d..bf70f107f 100644 --- a/components/api-server/plugins/roles/migration.go +++ b/components/api-server/plugins/roles/migration.go @@ -278,7 +278,7 @@ func migrationAddGatewayAdminRole() *gormigrate.Migration { } return &gormigrate.Migration{ - ID: "2026100600000001", + ID: "2026100600000004", Migrate: func(tx *gorm.DB) error { var existing Role if err := tx.Where("name = ?", RoleGatewayAdmin).First(&existing).Error; err == nil { From f7dc792ad7273ac77be548243b05658b2b04910a Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Wed, 7 Oct 2026 12:05:33 -0400 Subject: [PATCH 08/23] fix(gateway-access): require owner tier to grant over an existing owner (GAM-08) Grant called authorizeManage with a hard-coded empty currentTier, so the owner-tier gate never saw when the grant target was already an owner. A gateway:admin could therefore demote or alter an existing gateway:owner via grant, bypassing GAM-08 owner-tier gating. Once the target user is resolved and the gateway lock is held, look up the target's actual current tier and re-run authorizeManage with it (mirroring ChangeRole/Revoke). The early pre-check stays so unauthorized callers are rejected before the directory lookup and user pre-provisioning side effects; fresh grants still pass since userTier returns "" for a user with no binding. Add TestGrant_AdminCannotDemoteOwner, which fails without the fix and passes with it. Co-Authored-By: Claude Opus 4.8 --- .../plugins/gatewayAccess/integration_test.go | 17 +++++++++++++++++ .../api-server/plugins/gatewayAccess/service.go | 11 +++++++++++ 2 files changed, 28 insertions(+) diff --git a/components/api-server/plugins/gatewayAccess/integration_test.go b/components/api-server/plugins/gatewayAccess/integration_test.go index 73e6dbdc9..28aeb1930 100644 --- a/components/api-server/plugins/gatewayAccess/integration_test.go +++ b/components/api-server/plugins/gatewayAccess/integration_test.go @@ -191,6 +191,23 @@ func TestGrant_AdminCanGrantUser(t *testing.T) { Expect(item.Role).To(Equal(gatewayAccess.TierUser)) } +// A Grant that targets a user who is already an owner is a change, not a fresh +// grant; an admin must not be able to use it to demote or alter an owner (GAM-08). +func TestGrant_AdminCannotDemoteOwner(t *testing.T) { + test.RegisterIntegration(t) + defer gatewayAccess.SetDirectoryResolver(nil) + gw := "gw-admin-nodemote-owner" + seed(t, gw, "owner-dm", roles.RoleGatewayOwner) // existing owner (target) + seed(t, gw, "owner-dm2", roles.RoleGatewayOwner) // second owner, so last-owner guard is not the cause + admin := seed(t, gw, "admin-dm", roles.RoleGatewayAdmin) + + dir := realm(gatewayAccess.DirectoryUser{Username: "owner-dm", Name: "Owner DM"}) + svc := accessSvc(dir) + _, aerr := svc.Grant(context.Background(), gw, admin, gatewayAccess.GrantInput{Username: "owner-dm", Role: gatewayAccess.TierUser}) + Expect(aerr).NotTo(BeNil()) + Expect(aerr.Status).To(Equal(http.StatusForbidden)) +} + func TestChangeRole_PromoteInPlaceKeepsBindingID(t *testing.T) { test.RegisterIntegration(t) defer gatewayAccess.SetDirectoryResolver(nil) diff --git a/components/api-server/plugins/gatewayAccess/service.go b/components/api-server/plugins/gatewayAccess/service.go index 0ac802750..e1170b1cd 100644 --- a/components/api-server/plugins/gatewayAccess/service.go +++ b/components/api-server/plugins/gatewayAccess/service.go @@ -205,6 +205,17 @@ func (s *service) Grant(ctx context.Context, gatewayID, callerUserID string, inp } defer release() + // A grant that converges an existing owner is a change, not a fresh grant + // (GAM-10): re-run owner-tier gating under the lock now that the target's + // current tier is known, so an admin cannot demote or alter an owner (GAM-08). + current, aerr := s.userTier(ctx, targetUserID, gatewayID) + if aerr != nil { + return GrantItem{}, aerr + } + if aerr := s.authorizeManage(ctx, callerUserID, gatewayID, input.Role, current); aerr != nil { + return GrantItem{}, aerr + } + // A grant that converges an existing owner to a lower tier is a change // (GAM-10); apply last-owner protection. if aerr := s.guardLastOwner(ctx, gatewayID, targetUserID, input.Role); aerr != nil { From cab58f849a93a41fe3742a18761f9d6a17248938 Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Wed, 7 Oct 2026 15:14:21 -0400 Subject: [PATCH 09/23] fix(e2e): relabel openshell CLI config mount for SELinux hosts The containerized openshell CLI wrapper bind-mounted the host config without an SELinux relabel flag, so on enforcing hosts (Fedora/RHEL) the CLI could not read its config and reported "No gateway configured", failing the e2e gateway-connectivity area. Add a shared :z relabel; the suite issues concurrent CLI calls (background sandbox creation), so a shared label is idempotent and safe where private :Z would thrash. Co-Authored-By: Claude Opus 4.8 --- scripts/kind/openshell-container.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/kind/openshell-container.sh b/scripts/kind/openshell-container.sh index b4ffeb8a5..73d308a75 100755 --- a/scripts/kind/openshell-container.sh +++ b/scripts/kind/openshell-container.sh @@ -96,7 +96,12 @@ RUN_ARGS+=( --tmpfs "/tmp/osh-home:mode=1777" -e HOME=/tmp/osh-home -e OPENSHELL_GATEWAY_INSECURE="${OPENSHELL_GATEWAY_INSECURE:-true}" - -v "${HOST_CONFIG}:/tmp/osh-home/.config/openshell" + # :z relabels the bind mount for SELinux-enforcing hosts (Fedora/RHEL); + # without it the containerized CLI cannot read the host config and reports + # "No gateway configured". Shared (:z), not private (:Z): the suite issues + # concurrent CLI calls (background sandbox creation), and a shared label is + # idempotent and safe across overlapping invocations where :Z would thrash. + -v "${HOST_CONFIG}:/tmp/osh-home/.config/openshell:z" ) exec "${CONTAINER_ENGINE}" run "${RUN_ARGS[@]}" "${CLI_IMAGE}" "$@" From b4c6c5af87434b5d175821227242c98e019497e7 Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Wed, 7 Oct 2026 16:52:59 -0400 Subject: [PATCH 10/23] feat(e2e): gateway access management e2e with Keycloak directory pickup Seed developer-owned extra realm users (alice/bob/carol/dana) via keycloak_reconcile_extra_users, and add e2e area 9b exercising the gateway-access-management facade (owner/admin/viewer tiers) and the Keycloak directory search (GAM-09). Verify that a realm user added to Keycloak after the platform is running (ellen, not seeded by make kind-up) is picked up by the control-plane directory projection, becomes searchable, and on grant is pre-provisioned as a HyperShell User record in the database. Shorten the directory refresh interval in the kind e2e controller timing patch (same single rollout as the namespace GC timing) so the refresh lands within the wait window. Co-Authored-By: Claude Opus 4.8 --- scripts/cluster/reconcile-test-users.sh | 61 ++- scripts/cluster/reconcile-test-users_test.sh | 30 ++ scripts/kind/lib.sh | 15 + scripts/kind/up.sh | 2 + tests/e2e/drivers/kind.sh | 52 ++- tests/e2e/e2e-openshell.sh | 460 +++++++++++++++++++ 6 files changed, 606 insertions(+), 14 deletions(-) diff --git a/scripts/cluster/reconcile-test-users.sh b/scripts/cluster/reconcile-test-users.sh index dd6be2483..385d18437 100644 --- a/scripts/cluster/reconcile-test-users.sh +++ b/scripts/cluster/reconcile-test-users.sh @@ -140,15 +140,16 @@ _assign_realm_role() { fi } +# _create_or_reset_user [role...] +# Idempotently creates the user (or resets its password if it already exists) and +# assigns each named realm role. Profile fields and roles are passed explicitly so +# both the test-tier principals and developer-owned extra users (local grant +# targets) share one create/reset/role-assign path. _create_or_reset_user() { - local token="$1" username="$2" password="$3" + local token="$1" username="$2" password="$3" first="$4" last="$5" email="$6" + shift 6 local base="${KEYCLOAK_BASE_URL%/}" - local user_id first last email profile rest code - profile="$(_test_user_profile "${username}")" - first="${profile%%$'\t'*}" - rest="${profile#*$'\t'}" - last="${rest%%$'\t'*}" - email="${rest#*$'\t'}" + local user_id code role user_id="$(_lookup_user_id "${token}" "${username}")" if [[ -z "${user_id}" ]]; then @@ -187,11 +188,10 @@ _create_or_reset_user() { echo "Keycloak user ${username} was not found after create" >&2 return 1 fi - local role - while IFS= read -r role; do + for role in "$@"; do [[ -z "${role}" ]] && continue _assign_realm_role "${token}" "${user_id}" "${role}" || return 1 - done < <(_test_user_roles "${username}") + done } keycloak_reconcile_test_users() { @@ -219,7 +219,46 @@ keycloak_reconcile_test_users() { echo "No password loaded for ${username}" >&2 return 1 fi - _create_or_reset_user "${token}" "${username}" "${password}" || return 1 + local profile first last email rest role roles + profile="$(_test_user_profile "${username}")" + first="${profile%%$'\t'*}" + rest="${profile#*$'\t'}" + last="${rest%%$'\t'*}" + email="${rest#*$'\t'}" + roles=() + while IFS= read -r role; do + [[ -z "${role}" ]] && continue + roles+=("${role}") + done < <(_test_user_roles "${username}") + _create_or_reset_user "${token}" "${username}" "${password}" \ + "${first}" "${last}" "${email}" "${roles[@]}" || return 1 + done +} + +# keycloak_reconcile_extra_users ... +# Developer-owned helper: create-or-reset each given realm user with its password +# fixed to its username and the base `hypershell-users` realm role. These are plain +# users for local feature testing - e.g. Kind grant targets for +# gateway-access-management (owner/admin/viewer) and the Keycloak directory search. +# They are NOT test-tier principals and are NOT seeded on CI-owned environments. +# Idempotent. +keycloak_reconcile_extra_users() { + local token username + if [[ -z "${KEYCLOAK_BASE_URL:-}" ]]; then + echo "KEYCLOAK_BASE_URL is required" >&2 + return 1 + fi + if [[ "$#" -eq 0 ]]; then + return 0 + fi + token="$(keycloak_admin_token)" + if [[ -z "${token}" ]]; then + echo "Could not obtain Keycloak master admin token" >&2 + return 1 + fi + for username in "$@"; do + _create_or_reset_user "${token}" "${username}" "${username}" \ + "${username^}" "User" "${username}@hypershell.local" hypershell-users || return 1 done } diff --git a/scripts/cluster/reconcile-test-users_test.sh b/scripts/cluster/reconcile-test-users_test.sh index 5cd84f13d..85edc261f 100644 --- a/scripts/cluster/reconcile-test-users_test.sh +++ b/scripts/cluster/reconcile-test-users_test.sh @@ -141,5 +141,35 @@ else echo 'FAIL: de-seed did not look up developer' fi +# keycloak_reconcile_extra_users: create-or-reset a plain user + assign its role. +# The curl stub reports every user as existing (reset-password path) and returns a +# role object for any /roles/ lookup, so we can assert the generic path ran. +cat >"${WORKDIR}/curl" <>"${CURL_LOG}" +if [[ " \$* " == *"/token"* ]]; then + printf '%s' '{"access_token":"tok"}'; exit 0 +fi +if [[ " \$* " == *"users?username="* ]]; then + printf '%s' '[{"id":"uid-1"}]'; exit 0 +fi +if [[ " \$* " == *"/roles/"* ]]; then + printf '%s' '{"id":"rid","name":"hypershell-users"}'; exit 0 +fi +printf '%s' '204' +EOF +chmod +x "${WORKDIR}/curl" +: >"${CURL_LOG}" +assert_ok "reconcile extra users" keycloak_reconcile_extra_users alice +if grep -q 'username=alice' "${CURL_LOG}" \ + && grep -q 'reset-password' "${CURL_LOG}" \ + && grep -q 'role-mappings/realm' "${CURL_LOG}"; then + PASS=$((PASS + 1)) +else + FAIL=$((FAIL + 1)) + echo 'FAIL: extra-user reconcile did not create/reset and assign a role' +fi +assert_ok "reconcile extra users with none is a no-op" keycloak_reconcile_extra_users + printf 'reconcile-test-users tests: %d passed, %d failed\n' "${PASS}" "${FAIL}" [[ "${FAIL}" -eq 0 ]] diff --git a/scripts/kind/lib.sh b/scripts/kind/lib.sh index 8ee160e71..988ad98ce 100755 --- a/scripts/kind/lib.sh +++ b/scripts/kind/lib.sh @@ -579,6 +579,12 @@ stop_port_forward() { # --- Keycloak seed-user reconciliation --- +# Extra plain realm users seeded only in Kind, as grant targets for local +# gateway-access-management testing (grant owner/admin/viewer to another user, +# transfer/last-owner protection, directory search). Override to seed more. +: "${KIND_EXTRA_USERNAMES:=alice bob carol dana}" +read -r -a KIND_EXTRA_USERNAMES <<<"${KIND_EXTRA_USERNAMES}" + # Aligns live Keycloak users with the static developer-owned seeds # (ephemeral-test-credentials.spec.md). Idempotent. reconcile_keycloak_seed_users() { @@ -599,4 +605,13 @@ reconcile_keycloak_seed_users() { return 1 fi success "Keycloak test-tier principals reconciled (admin/admin, developer/developer, platform-admin/platform-admin)" + + # Plain grant-target users for local gateway-access-management testing + # (owner/admin/viewer tiers + Keycloak directory search). Kind-only; not + # test-tier principals, not seeded on CI-owned environments. Password == username. + if ! keycloak_reconcile_extra_users "${KIND_EXTRA_USERNAMES[@]}"; then + error "Failed to reconcile Keycloak grant-target users" + return 1 + fi + success "Keycloak grant-target users reconciled (${KIND_EXTRA_USERNAMES[*]}; password == username)" } diff --git a/scripts/kind/up.sh b/scripts/kind/up.sh index cc0b14c18..cdc3e8572 100755 --- a/scripts/kind/up.sh +++ b/scripts/kind/up.sh @@ -988,6 +988,7 @@ if [[ "${CPK_RUNNING}" == "true" ]]; then info "Login: https://${CONSOLE_HOSTNAME}${PORT_SUFFIX}/auth/login" info "Test users: admin/admin (admins + users), developer/developer (users only)" + info "Grant targets: ${KIND_EXTRA_USERNAMES[*]} (password == username; for gateway access testing)" else info "HTTP API: http://localhost:8000" info "Web Console: http://localhost:3000" @@ -1006,6 +1007,7 @@ else info "Login: http://localhost:3000/auth/login" info "Test users: admin/admin (admins + users), developer/developer (users only)" + info "Grant targets: ${KIND_EXTRA_USERNAMES[*]} (password == username; for gateway access testing)" echo "" warn "Running without cloud-provider-kind - no TLS or hostname-based routing." diff --git a/tests/e2e/drivers/kind.sh b/tests/e2e/drivers/kind.sh index a4229a659..d2ce24383 100755 --- a/tests/e2e/drivers/kind.sh +++ b/tests/e2e/drivers/kind.sh @@ -434,6 +434,10 @@ de_seed_test_users() { : "${E2E_GATEWAY_NAMESPACE_GC_INTERVAL:=30s}" : "${E2E_GATEWAY_NAMESPACE_GC_GRACE_PERIOD:=30s}" +# Directory projection refresh (GAM-09) defaults to 5m in code, too slow for area +# 9b to observe a realm user added at test time. Shortened in the same controller +# rollout as the GC timing (below) so there is only one restart, done up front. +: "${E2E_GATEWAY_DIRECTORY_REFRESH_INTERVAL:=15s}" _GC_TIMING_PATCHED="" # _patch_namespace_gc_timing / _restore_namespace_gc_timing - shared @@ -447,10 +451,11 @@ _GC_TIMING_PATCHED="" # first (e.g. OpenShift) override the public functions and delegate here. _patch_namespace_gc_timing() { local cli="$1" namespace="$2" - dim " Shortening controller namespace GC timing for e2e (interval=${E2E_GATEWAY_NAMESPACE_GC_INTERVAL}, grace=${E2E_GATEWAY_NAMESPACE_GC_GRACE_PERIOD})..." + dim " Shortening controller namespace GC + directory-refresh timing for e2e (gc-interval=${E2E_GATEWAY_NAMESPACE_GC_INTERVAL}, grace=${E2E_GATEWAY_NAMESPACE_GC_GRACE_PERIOD}, directory-refresh=${E2E_GATEWAY_DIRECTORY_REFRESH_INTERVAL})..." if ! "$cli" set env deployment/hypershell-controller -n "$namespace" -c controller \ "GATEWAY_NAMESPACE_GC_INTERVAL=${E2E_GATEWAY_NAMESPACE_GC_INTERVAL}" \ - "GATEWAY_NAMESPACE_GC_GRACE_PERIOD=${E2E_GATEWAY_NAMESPACE_GC_GRACE_PERIOD}" >/dev/null; then + "GATEWAY_NAMESPACE_GC_GRACE_PERIOD=${E2E_GATEWAY_NAMESPACE_GC_GRACE_PERIOD}" \ + "GATEWAY_DIRECTORY_REFRESH_INTERVAL=${E2E_GATEWAY_DIRECTORY_REFRESH_INTERVAL}" >/dev/null; then red " Failed to patch hypershell-controller namespace GC timing" return 1 fi @@ -466,7 +471,7 @@ _restore_namespace_gc_timing() { [[ -n "$_GC_TIMING_PATCHED" ]] || return 0 dim " Restoring controller namespace GC timing to deployment defaults..." "$cli" set env deployment/hypershell-controller -n "$namespace" -c controller \ - GATEWAY_NAMESPACE_GC_INTERVAL- GATEWAY_NAMESPACE_GC_GRACE_PERIOD- >/dev/null 2>&1 || true + GATEWAY_NAMESPACE_GC_INTERVAL- GATEWAY_NAMESPACE_GC_GRACE_PERIOD- GATEWAY_DIRECTORY_REFRESH_INTERVAL- >/dev/null 2>&1 || true "$cli" rollout status deployment/hypershell-controller -n "$namespace" --timeout=300s >/dev/null 2>&1 || true _GC_TIMING_PATCHED="" } @@ -614,6 +619,47 @@ assign_realm_role() { fi } +# kc_create_user - idempotently create a realm user in Keycloak, mirroring the +# shape of the `make kind-up` extra users (scripts/cluster/reconcile-test-users.sh): +# profile fields derived from the username, enabled, no roles or password (the +# directory projection only needs username/name/email/subject, and a grant target +# need never have signed in). Used by area 9b to add a brand-new realm user at test +# time and prove the control-plane directory projection (GAM-09) picks it up. +# Idempotent: an already-existing user is success. +# Usage: kc_create_user +kc_create_user() { + local username="${1:?username required}" + local base realm + base="$(_kc_base)" + realm="$(_kc_realm)" + + if ! _kc_admin_token; then + red " Failed to obtain Keycloak admin token (user=${E2E_KC_ADMIN_USER})" + return 1 + fi + + local existing + existing=$(_driver_curl -H "Authorization: Bearer ${_KC_ADMIN_TOKEN}" \ + "${base}/admin/realms/${realm}/users?username=${username}&exact=true" 2>/dev/null \ + | python3 -c "import json,sys; a=json.load(sys.stdin); print(a[0]['id'] if a else '')" 2>/dev/null || true) + if [[ -n "$existing" ]]; then + return 0 + fi + + local body code + body=$(USERNAME="$username" python3 -c "import json,os; u=os.environ['USERNAME']; print(json.dumps({ + 'username': u, 'firstName': u.capitalize(), 'lastName': 'User', + 'email': u + '@hypershell.local', 'emailVerified': True, 'enabled': True}))") + code=$(_driver_curl -o /dev/null -w '%{http_code}' -X POST \ + -H "Authorization: Bearer ${_KC_ADMIN_TOKEN}" \ + -H "Content-Type: application/json" \ + "${base}/admin/realms/${realm}/users" -d "$body" 2>/dev/null || true) + if [[ "$code" != "201" && "$code" != "204" ]]; then + red " Failed to create Keycloak user ${username} (HTTP ${code})" + return 1 + fi +} + # _token_has_role - check whether a JWT carries a client role in the nested # hypershell.roles claim (the claim path the gateway is configured with). Falls # back to a flat "hypershell.roles" key for robustness. Returns 0 if present. diff --git a/tests/e2e/e2e-openshell.sh b/tests/e2e/e2e-openshell.sh index ee8ef4a96..1cb91ce85 100755 --- a/tests/e2e/e2e-openshell.sh +++ b/tests/e2e/e2e-openshell.sh @@ -232,6 +232,7 @@ printf ' %s\n' "6. Gateway connectivity" printf ' %s\n' "7. Sandbox lifecycle (create → ready)" printf ' %s\n' "8. Sandbox interaction + active sandbox count" printf ' %s\n' "9. Developer user RBAC verification" +printf ' %s\n' "9b. Gateway access management + directory pickup of a new Keycloak user [kind, long]" printf ' %s\n' "10. Platform admin RBAC verification" printf ' %s\n' "11. Gateway deletion + namespace garbage collection" printf ' %s\n' "12. ManagedCluster registration + control-plane identity [long]" @@ -1852,6 +1853,465 @@ fi fi sep +# --- 9b. gateway access management (secondary users) ---------------------- +# +# Exercises the gateway-access-management facade end to end with the secondary +# Keycloak users seeded by `make kind-up` +# (scripts/cluster/reconcile-test-users.sh): an owner grants a normal user, a +# granted admin, and a granted owner through POST /gateways/{id}/access, and the +# Owner > Admin > Viewer authorization hierarchy and the Keycloak Role Bridge are +# asserted (gateway-access-management.spec.md GAM-02..GAM-09). +# +# 1. normal user (role "user" = gateway:viewer) can create a sandbox +# 2. granted admin (role "admin" = gateway:admin) can remove the normal user +# 3. granted owner (role "owner" = gateway:owner) can remove the granted admin, +# and can delete a gateway +# +# Requires a resource-owner password grant against the seeded secondary users. +# Brokered CI (E2E_OIDC_GRANT=client_credentials) cannot password-grant them and +# they are not impersonation targets (ephemeral-test-credentials.spec.md), so this +# area is Kind / local-OpenShift (password grant), long mode only. + +echo "" +e2e_area "9b. Gateway Access Management (secondary users)" +echo "" + +: "${E2E_GAM_USER:=alice}" # normal user -> role "user" (gateway:viewer) +: "${E2E_GAM_ADMIN:=bob}" # granted admin -> role "admin" (gateway:admin) +: "${E2E_GAM_OWNER:=carol}" # granted owner -> role "owner" (gateway:owner) +: "${E2E_GAM_VIEWER:=dana}" # second normal user the granted admin adds as viewer + +if ! e2e_step long; then + dim " Skipped (E2E_MODE=${E2E_MODE}): access-management matrix acts as several secondary user identities" +elif [[ "${E2E_INFRA_DRIVER}" != "kind" ]]; then + dim " Skipped (E2E_INFRA_DRIVER=${E2E_INFRA_DRIVER}): the secondary users (${E2E_GAM_USER}/${E2E_GAM_ADMIN}/${E2E_GAM_OWNER}) are seeded only by 'make kind-up' (scripts/cluster/reconcile-test-users.sh)" +elif [[ "${E2E_OIDC_GRANT:-password}" != "password" ]]; then + dim " Skipped (E2E_OIDC_GRANT=${E2E_OIDC_GRANT}): secondary users (${E2E_GAM_USER}/${E2E_GAM_ADMIN}/${E2E_GAM_OWNER}) need a resource-owner password grant; brokered CI cannot mint their tokens" +elif [[ -z "${GW_ID}" ]]; then + fail_test "Access management: no gateway id (area 2 did not provision a gateway)" +else + GAM_LAST_BODY="" + + # --- small helpers over the access facade (keyed by gateway id + bearer) --- + # Poll until the realm-user projection (GAM-09) lists the user, so a just-seeded + # user is grantable (the projection has bounded staleness). + gam_wait_directory() { # + local gw="$1" token="$2" uname="$3" deadline + deadline=$(($(date +%s) + 120)) + while [[ $(date +%s) -lt $deadline ]]; do + if _driver_curl -H "Authorization: Bearer ${token}" \ + "${API_HOST}/api/hypershell/v1/gateways/${gw}/access/directory?search=${uname}" 2>/dev/null \ + | UNAME="$uname" python3 -c "import json,sys,os +u=os.environ['UNAME'] +sys.exit(0 if any(i.get('username')==u for i in json.load(sys.stdin).get('items',[])) else 1)" 2>/dev/null; then + return 0 + fi + sleep 5 + done + return 1 + } + gam_grant() { # -> prints HTTP code; body in GAM_LAST_BODY + local gw="$1" token="$2" uname="$3" role="$4" f code body + f=$(mktemp) + body=$(UNAME="$uname" ROLE="$role" python3 -c "import json,os;print(json.dumps({'username':os.environ['UNAME'],'role':os.environ['ROLE']}))") + code=$(_driver_curl -o "$f" -w '%{http_code}' -X POST \ + -H "Authorization: Bearer ${token}" -H "Content-Type: application/json" \ + "${API_HOST}/api/hypershell/v1/gateways/${gw}/access" -d "$body" 2>/dev/null || true) + GAM_LAST_BODY=$(sed 's/\x1b\[[0-9;]*m//g' "$f" 2>/dev/null | tr '\n' ' ' | tr -s ' '); rm -f "$f" + printf '%s' "$code" + } + gam_revoke() { # -> prints HTTP code; body in GAM_LAST_BODY + local gw="$1" token="$2" uid="$3" f code + f=$(mktemp) + code=$(_driver_curl -o "$f" -w '%{http_code}' -X DELETE \ + -H "Authorization: Bearer ${token}" \ + "${API_HOST}/api/hypershell/v1/gateways/${gw}/access/${uid}" 2>/dev/null || true) + GAM_LAST_BODY=$(sed 's/\x1b\[[0-9;]*m//g' "$f" 2>/dev/null | tr '\n' ' ' | tr -s ' '); rm -f "$f" + printf '%s' "$code" + } + gam_user_id() { # -> prints HyperShell user_id (via GAM-03 list) + local gw="$1" token="$2" uname="$3" + _driver_curl -H "Authorization: Bearer ${token}" \ + "${API_HOST}/api/hypershell/v1/gateways/${gw}/access" 2>/dev/null \ + | UNAME="$uname" python3 -c "import json,sys,os +u=os.environ['UNAME'] +print(next((i.get('user_id','') for i in json.load(sys.stdin).get('items',[]) if i.get('username')==u),''))" 2>/dev/null || true + } + gam_user_role() { # -> prints the user's tier (owner/admin/user) or empty + local gw="$1" token="$2" uname="$3" + _driver_curl -H "Authorization: Bearer ${token}" \ + "${API_HOST}/api/hypershell/v1/gateways/${gw}/access" 2>/dev/null \ + | UNAME="$uname" python3 -c "import json,sys,os +u=os.environ['UNAME'] +print(next((i.get('role','') for i in json.load(sys.stdin).get('items',[]) if i.get('username')==u),''))" 2>/dev/null || true + } + + # The admin user created the main gateway in area 2, so it is the first + # gateway:owner (security/rbac-enforcement.spec.md) and may grant every tier. + acquire_oidc_token 2>/dev/null || true + GAM_OWNER_API_TOKEN="${_OIDC_ACCESS_TOKEN}" + [[ -n "$GAM_OWNER_API_TOKEN" ]] || fail_test "Access management: failed to acquire owner (admin) API token" + + # == Scenario 0: a realm user added to Keycloak AFTER the platform is running is + # picked up by the control-plane directory projection (GAM-09 periodic refresh) + # and becomes searchable, then grantable. Unlike alice/bob/carol/dana, ellen is + # NOT seeded by `make kind-up`, so this proves the platform reflects a brand-new + # Keycloak user into its directory, and -- on grant -- pre-provisions a HyperShell + # User record in its database. The directory refresh interval was shortened up + # front (kind driver GC-timing patch) so the refresh lands within the wait. == + E2E_GAM_NEW_USER="${E2E_GAM_NEW_USER:-ellen}" + show_cmd "# create Keycloak realm user ${E2E_GAM_NEW_USER} (not seeded by make kind-up)" + if kc_create_user "$E2E_GAM_NEW_USER"; then + pass "Created Keycloak realm user ${E2E_GAM_NEW_USER} at test time" + else + fail_test "Access management: could not create Keycloak user ${E2E_GAM_NEW_USER}" + fi + + show_cmd "# await ${E2E_GAM_NEW_USER} in the directory projection (control plane picks up the new realm user)" + if gam_wait_directory "$GW_ID" "$GAM_OWNER_API_TOKEN" "$E2E_GAM_NEW_USER"; then + pass "Directory search resolves ${E2E_GAM_NEW_USER} after it was added to Keycloak (projection refreshed)" + else + fail_test "Access management: ${E2E_GAM_NEW_USER} never appeared in the directory projection after Keycloak create" + fi + + show_cmd "POST ${API_HOST}/api/hypershell/v1/gateways/${GW_ID}/access {user: ${E2E_GAM_NEW_USER}, role: user}" + GAM_CODE=$(gam_grant "$GW_ID" "$GAM_OWNER_API_TOKEN" "$E2E_GAM_NEW_USER" user) + if [[ "$GAM_CODE" =~ ^2 ]]; then + pass "Owner granted ${E2E_GAM_NEW_USER} the 'user' tier (HTTP ${GAM_CODE})" + else + fail_test "Access management: grant 'user' to ${E2E_GAM_NEW_USER} returned HTTP ${GAM_CODE:-none}" + dim " ${GAM_LAST_BODY:0:200}" + fi + GAM_NEW_USER_ID=$(gam_user_id "$GW_ID" "$GAM_OWNER_API_TOKEN" "$E2E_GAM_NEW_USER") + if [[ -n "$GAM_NEW_USER_ID" ]]; then + pass "${E2E_GAM_NEW_USER} resolves to a pre-provisioned HyperShell user_id in the access list (${GAM_NEW_USER_ID})" + else + fail_test "Access management: ${E2E_GAM_NEW_USER} has no HyperShell user_id after grant (not persisted to the database)" + fi + + # == Scenario 1: normal user is granted "user" and can create a sandbox == + show_cmd "# wait for ${E2E_GAM_USER} in the gateway directory projection (GAM-09)" + if gam_wait_directory "$GW_ID" "$GAM_OWNER_API_TOKEN" "$E2E_GAM_USER"; then + pass "Directory search resolves ${E2E_GAM_USER} (realm-user projection populated)" + else + fail_test "Access management: ${E2E_GAM_USER} never appeared in the directory projection" + fi + + show_cmd "POST ${API_HOST}/api/hypershell/v1/gateways/${GW_ID}/access {user: ${E2E_GAM_USER}, role: user}" + GAM_CODE=$(gam_grant "$GW_ID" "$GAM_OWNER_API_TOKEN" "$E2E_GAM_USER" user) + if [[ "$GAM_CODE" =~ ^2 ]]; then + pass "Owner granted ${E2E_GAM_USER} the 'user' tier (gateway:viewer) (HTTP ${GAM_CODE})" + else + fail_test "Access management: grant 'user' to ${E2E_GAM_USER} returned HTTP ${GAM_CODE:-none}" + dim " ${GAM_LAST_BODY:0:200}" + fi + + # The Role Bridge (GAM-02) projects gateway:viewer -> openshell-user on the + # per-gateway Keycloak client. Poll the normal user's per-gateway token until + # that client role appears: the end-to-end proof the grant reconciled. + show_cmd "# acquire ${E2E_GAM_USER} per-gateway token, await Role Bridge openshell-user" + if acquire_gateway_token_with_role "$E2E_GAM_USER" "$E2E_GAM_USER" "$GW_KC_CLIENT_ID" openshell-user; then + GAM_USER_GW_TOKEN="${_OIDC_ACCESS_TOKEN}" + pass "Role Bridge projected openshell-user to ${E2E_GAM_USER} (gateway:viewer -> openshell-user)" + else + GAM_USER_GW_TOKEN="" + fail_test "Access management: Role Bridge did not project openshell-user to ${E2E_GAM_USER}" + fi + acquire_oidc_token 2>/dev/null || true # restore owner token + + if [[ -n "$GAM_USER_GW_TOKEN" ]]; then + # gateway:viewer / openshell-user authorizes sandbox creation only inside a + # workspace the user is a member of. The access facade grants the gateway + # role, NOT OpenShell workspace membership (a separate gateway-side concern; + # see area 9). An owner/admin must still add the user to 'default' first. + GAM_USER_SUBJECT=$(GAM_TOK="$GAM_USER_GW_TOKEN" python3 -c " +import os,json,base64 +try: + p=os.environ['GAM_TOK'].split('.')[1]; p+='='*(-len(p)%4) + print(json.loads(base64.urlsafe_b64decode(p)).get('sub','') or '') +except Exception: + pass" 2>/dev/null || true) + if [[ -z "$GAM_USER_SUBJECT" ]]; then + fail_test "Access management: could not resolve ${E2E_GAM_USER} OIDC subject for workspace membership" + else + show_cmd "${OPENSHELL_BIN} -g ${GW_LOCAL_NAME} workspace member add --workspace default --subject ${GAM_USER_SUBJECT} --role user" + GAM_WM_LOG=$(mktemp) + if "${OPENSHELL_BIN}" -g "${GW_LOCAL_NAME}" workspace member add \ + --workspace default --subject "${GAM_USER_SUBJECT}" --role user >"${GAM_WM_LOG}" 2>&1 \ + || grep -qiE "already|exists" "${GAM_WM_LOG}"; then + pass "Owner added ${E2E_GAM_USER} as a 'user' member of 'default' workspace" + else + fail_test "Access management: failed to add ${E2E_GAM_USER} to 'default' workspace" + dim " $(sed 's/\x1b\[[0-9;]*m//g' "${GAM_WM_LOG}" | tr '\n' ' ' | tr -s ' ' | cut -c1-200)" + fi + rm -f "${GAM_WM_LOG}" + + # register a CLI config for the normal user and create a sandbox + GAM_USER_GW_LOCAL="${GW_LOCAL_NAME}-${E2E_GAM_USER}" + GAM_USER_CFG="${HOME}/.config/openshell/gateways/${GAM_USER_GW_LOCAL}" + "${OPENSHELL_BIN}" gateway remove "${GAM_USER_GW_LOCAL}" 2>/dev/null || true + mkdir -p "${GAM_USER_CFG}" + GAM_USER_GW_LOCAL="$GAM_USER_GW_LOCAL" GW_ENDPOINT="$GW_ENDPOINT" \ + E2E_OIDC_ISSUER="$E2E_OIDC_ISSUER" GW_KC_CLIENT_ID="$GW_KC_CLIENT_ID" \ + GAM_USER_GW_TOKEN="$GAM_USER_GW_TOKEN" GAM_USER_CFG="$GAM_USER_CFG" python3 -c " +import json, os +d=os.environ['GAM_USER_CFG'] +json.dump({'name':os.environ['GAM_USER_GW_LOCAL'],'gateway_endpoint':os.environ['GW_ENDPOINT'], + 'is_remote':True,'gateway_port':0,'auth_mode':'oidc','oidc_issuer':os.environ['E2E_OIDC_ISSUER'], + 'oidc_client_id':os.environ['GW_KC_CLIENT_ID'], + 'gateway_insecure':bool(os.environ.get('OPENSHELL_GATEWAY_INSECURE',''))}, open(os.path.join(d,'metadata.json'),'w'), indent=2) +json.dump({'access_token':os.environ['GAM_USER_GW_TOKEN'],'issuer':os.environ['E2E_OIDC_ISSUER'], + 'client_id':os.environ['GW_KC_CLIENT_ID']}, open(os.path.join(d,'oidc_token.json'),'w'), indent=2) +os.chmod(os.path.join(d,'metadata.json'),0o600); os.chmod(os.path.join(d,'oidc_token.json'),0o600) +" + GAM_SANDBOX="e2e-gam-$(date +%s | tail -c5)" + show_cmd "${OPENSHELL_BIN} -g ${GAM_USER_GW_LOCAL} sandbox create --name ${GAM_SANDBOX}" + dim " Expecting success (${E2E_GAM_USER} is a 'user' member of 'default' with openshell-user)..." + GAM_SB_LOG=$(mktemp) + "${OPENSHELL_BIN}" -g "${GAM_USER_GW_LOCAL}" sandbox create --name "${GAM_SANDBOX}" >"${GAM_SB_LOG}" 2>&1 & + GAM_SB_PID=$! + GAM_SB_OK=false + GAM_SB_DEADLINE=$(($(date +%s) + E2E_SANDBOX_TIMEOUT)) + while [[ $(date +%s) -lt $GAM_SB_DEADLINE ]]; do + if $CLI get pods -n "$GW_NAMESPACE" --no-headers 2>/dev/null | grep -qi "default--${GAM_SANDBOX}"; then + GAM_SB_OK=true; break + fi + kill -0 "$GAM_SB_PID" 2>/dev/null || break + sleep 5 + done + kill "$GAM_SB_PID" 2>/dev/null || true; wait "$GAM_SB_PID" 2>/dev/null || true + if [[ "$GAM_SB_OK" == "true" ]]; then + pass "Normal user (${E2E_GAM_USER}): sandbox create allowed" + "${OPENSHELL_BIN}" -g "${GAM_USER_GW_LOCAL}" sandbox delete "${GAM_SANDBOX}" 2>/dev/null || true + else + fail_test "Normal user (${E2E_GAM_USER}): sandbox not created within ${E2E_SANDBOX_TIMEOUT}s" + dim " $(sed 's/\x1b\[[0-9;]*m//g' "${GAM_SB_LOG}" | tr '\n' ' ' | tr -s ' ' | cut -c1-200)" + fi + rm -f "${GAM_SB_LOG}" + "${OPENSHELL_BIN}" gateway remove "${GAM_USER_GW_LOCAL}" 2>/dev/null || true + fi + fi + + # -- negative: a viewer (user tier) cannot manage access (GAM-08). While ${E2E_GAM_USER} + # still holds the user tier from scenario 1, a grant attempt must be 403. -- + acquire_oidc_token "$E2E_GAM_USER" "$E2E_GAM_USER" 2>/dev/null || true + GAM_VIEWER_API_TOKEN="${_OIDC_ACCESS_TOKEN}" + acquire_oidc_token 2>/dev/null || true # restore owner token + if [[ -z "$GAM_VIEWER_API_TOKEN" ]]; then + fail_test "Access management: could not acquire ${E2E_GAM_USER} API token for the viewer-denied check" + else + show_cmd "POST .../gateways/${GW_ID}/access {user: ${E2E_GAM_VIEWER}, role: user} (as viewer ${E2E_GAM_USER}) -> expect 403" + GAM_CODE=$(gam_grant "$GW_ID" "$GAM_VIEWER_API_TOKEN" "$E2E_GAM_VIEWER" user) + if [[ "$GAM_CODE" == "403" ]]; then + pass "Normal user (${E2E_GAM_USER}, viewer) cannot add users (HTTP 403)" + else + fail_test "Access management: viewer ${E2E_GAM_USER} grant returned HTTP ${GAM_CODE:-none} (want 403)" + dim " ${GAM_LAST_BODY:0:200}" + fi + fi + + # == Scenario 2: granted admin can add another user as viewer, and remove a user == + gam_wait_directory "$GW_ID" "$GAM_OWNER_API_TOKEN" "$E2E_GAM_ADMIN" || true + show_cmd "POST .../gateways/${GW_ID}/access {user: ${E2E_GAM_ADMIN}, role: admin}" + GAM_CODE=$(gam_grant "$GW_ID" "$GAM_OWNER_API_TOKEN" "$E2E_GAM_ADMIN" admin) + if [[ "$GAM_CODE" =~ ^2 ]]; then + pass "Owner granted ${E2E_GAM_ADMIN} the 'admin' tier (gateway:admin) (HTTP ${GAM_CODE})" + else + fail_test "Access management: grant 'admin' to ${E2E_GAM_ADMIN} returned HTTP ${GAM_CODE:-none}" + dim " ${GAM_LAST_BODY:0:200}" + fi + + acquire_oidc_token "$E2E_GAM_ADMIN" "$E2E_GAM_ADMIN" 2>/dev/null || true + GAM_ADMIN_API_TOKEN="${_OIDC_ACCESS_TOKEN}" + acquire_oidc_token 2>/dev/null || true # restore owner token + if [[ -z "$GAM_ADMIN_API_TOKEN" ]]; then + fail_test "Access management: could not acquire ${E2E_GAM_ADMIN} API token for the admin management checks" + else + # -- a granted admin may grant the Admin and User tiers (GAM-08). Acting as + # bob, add a second user as viewer, searching the directory as the admin. -- + if gam_wait_directory "$GW_ID" "$GAM_ADMIN_API_TOKEN" "$E2E_GAM_VIEWER"; then + pass "Granted admin (${E2E_GAM_ADMIN}) can search the directory and resolve ${E2E_GAM_VIEWER} (GAM-08)" + else + fail_test "Access management: admin ${E2E_GAM_ADMIN} could not resolve ${E2E_GAM_VIEWER} via directory search" + fi + show_cmd "POST .../gateways/${GW_ID}/access {user: ${E2E_GAM_VIEWER}, role: user} (as admin ${E2E_GAM_ADMIN})" + GAM_CODE=$(gam_grant "$GW_ID" "$GAM_ADMIN_API_TOKEN" "$E2E_GAM_VIEWER" user) + if [[ "$GAM_CODE" =~ ^2 ]]; then + pass "Granted admin (${E2E_GAM_ADMIN}) added ${E2E_GAM_VIEWER} as viewer (user tier) (HTTP ${GAM_CODE})" + else + fail_test "Access management: admin grant of 'user' to ${E2E_GAM_VIEWER} returned HTTP ${GAM_CODE:-none}" + dim " ${GAM_LAST_BODY:0:200}" + fi + GAM_VIEWER_ROLE=$(gam_user_role "$GW_ID" "$GAM_OWNER_API_TOKEN" "$E2E_GAM_VIEWER") + if [[ "$GAM_VIEWER_ROLE" == "user" ]]; then + pass "${E2E_GAM_VIEWER} appears in the access list with role 'user'" + else + fail_test "Access management: ${E2E_GAM_VIEWER} role is '${GAM_VIEWER_ROLE:-}', want 'user'" + fi + + # -- negative: a granted admin may NOT touch the Owner tier (GAM-08). bob + # attempting to grant 'owner' must be 403 (no binding is created). -- + show_cmd "POST .../gateways/${GW_ID}/access {user: ${E2E_GAM_OWNER}, role: owner} (as admin ${E2E_GAM_ADMIN}) -> expect 403" + GAM_CODE=$(gam_grant "$GW_ID" "$GAM_ADMIN_API_TOKEN" "$E2E_GAM_OWNER" owner) + if [[ "$GAM_CODE" == "403" ]]; then + pass "Granted admin (${E2E_GAM_ADMIN}) cannot grant the owner tier (HTTP 403)" + else + fail_test "Access management: admin ${E2E_GAM_ADMIN} owner-grant returned HTTP ${GAM_CODE:-none} (want 403)" + dim " ${GAM_LAST_BODY:0:200}" + fi + + # -- a granted admin may also revoke a user-tier grant: remove the normal user -- + GAM_USER_ID=$(gam_user_id "$GW_ID" "$GAM_OWNER_API_TOKEN" "$E2E_GAM_USER") + if [[ -z "$GAM_USER_ID" ]]; then + fail_test "Access management: could not resolve ${E2E_GAM_USER} user_id for the admin-revoke check" + else + show_cmd "DELETE .../gateways/${GW_ID}/access/${GAM_USER_ID} (as admin ${E2E_GAM_ADMIN})" + GAM_CODE=$(gam_revoke "$GW_ID" "$GAM_ADMIN_API_TOKEN" "$GAM_USER_ID") + if [[ "$GAM_CODE" == "204" || "$GAM_CODE" == "200" ]]; then + pass "Granted admin (${E2E_GAM_ADMIN}) removed normal user (${E2E_GAM_USER}) (HTTP ${GAM_CODE})" + else + fail_test "Access management: admin revoke of ${E2E_GAM_USER} returned HTTP ${GAM_CODE:-none}" + dim " ${GAM_LAST_BODY:0:200}" + fi + if [[ -z "$(gam_user_id "$GW_ID" "$GAM_OWNER_API_TOKEN" "$E2E_GAM_USER")" ]]; then + pass "Normal user (${E2E_GAM_USER}) no longer appears in the gateway access list" + else + fail_test "Access management: ${E2E_GAM_USER} still has access after admin revoke" + fi + fi + fi + + # == Scenario 3: granted owner can remove the admin, and can delete a gateway == + gam_wait_directory "$GW_ID" "$GAM_OWNER_API_TOKEN" "$E2E_GAM_OWNER" || true + show_cmd "POST .../gateways/${GW_ID}/access {user: ${E2E_GAM_OWNER}, role: owner}" + GAM_CODE=$(gam_grant "$GW_ID" "$GAM_OWNER_API_TOKEN" "$E2E_GAM_OWNER" owner) + if [[ "$GAM_CODE" =~ ^2 ]]; then + pass "Owner granted ${E2E_GAM_OWNER} the 'owner' tier (gateway:owner) (HTTP ${GAM_CODE})" + else + fail_test "Access management: grant 'owner' to ${E2E_GAM_OWNER} returned HTTP ${GAM_CODE:-none}" + dim " ${GAM_LAST_BODY:0:200}" + fi + + acquire_oidc_token "$E2E_GAM_OWNER" "$E2E_GAM_OWNER" 2>/dev/null || true + GAM_OWNER2_API_TOKEN="${_OIDC_ACCESS_TOKEN}" + acquire_oidc_token 2>/dev/null || true # restore owner token + GAM_ADMIN_ID=$(gam_user_id "$GW_ID" "$GAM_OWNER_API_TOKEN" "$E2E_GAM_ADMIN") + if [[ -z "$GAM_OWNER2_API_TOKEN" || -z "$GAM_ADMIN_ID" ]]; then + fail_test "Access management: missing ${E2E_GAM_OWNER} token or ${E2E_GAM_ADMIN} user_id for the owner-revoke check" + else + show_cmd "DELETE .../gateways/${GW_ID}/access/${GAM_ADMIN_ID} (as owner ${E2E_GAM_OWNER})" + GAM_CODE=$(gam_revoke "$GW_ID" "$GAM_OWNER2_API_TOKEN" "$GAM_ADMIN_ID") + if [[ "$GAM_CODE" == "204" || "$GAM_CODE" == "200" ]]; then + pass "Granted owner (${E2E_GAM_OWNER}) removed granted admin (${E2E_GAM_ADMIN}) (HTTP ${GAM_CODE})" + else + fail_test "Access management: owner revoke of ${E2E_GAM_ADMIN} returned HTTP ${GAM_CODE:-none}" + dim " ${GAM_LAST_BODY:0:200}" + fi + fi + + # -- an owner may add any tier and remove anyone (GAM-04 / GAM-06). The owner + # adds one admin and one viewer, then removes both. ${E2E_GAM_USER} and + # ${E2E_GAM_ADMIN} currently hold no binding (both were revoked above), so + # these are clean adds. -- + if [[ -n "$GAM_OWNER2_API_TOKEN" ]]; then + gam_wait_directory "$GW_ID" "$GAM_OWNER2_API_TOKEN" "$E2E_GAM_USER" || true + show_cmd "POST .../gateways/${GW_ID}/access {user: ${E2E_GAM_USER}, role: admin} (as owner ${E2E_GAM_OWNER})" + GAM_CODE=$(gam_grant "$GW_ID" "$GAM_OWNER2_API_TOKEN" "$E2E_GAM_USER" admin) + if [[ "$GAM_CODE" =~ ^2 ]]; then + pass "Granted owner (${E2E_GAM_OWNER}) added ${E2E_GAM_USER} as admin (HTTP ${GAM_CODE})" + else + fail_test "Access management: owner grant of 'admin' to ${E2E_GAM_USER} returned HTTP ${GAM_CODE:-none}" + dim " ${GAM_LAST_BODY:0:200}" + fi + + gam_wait_directory "$GW_ID" "$GAM_OWNER2_API_TOKEN" "$E2E_GAM_ADMIN" || true + show_cmd "POST .../gateways/${GW_ID}/access {user: ${E2E_GAM_ADMIN}, role: user} (as owner ${E2E_GAM_OWNER})" + GAM_CODE=$(gam_grant "$GW_ID" "$GAM_OWNER2_API_TOKEN" "$E2E_GAM_ADMIN" user) + if [[ "$GAM_CODE" =~ ^2 ]]; then + pass "Granted owner (${E2E_GAM_OWNER}) added ${E2E_GAM_ADMIN} as viewer (HTTP ${GAM_CODE})" + else + fail_test "Access management: owner grant of 'user' to ${E2E_GAM_ADMIN} returned HTTP ${GAM_CODE:-none}" + dim " ${GAM_LAST_BODY:0:200}" + fi + + GAM_NEW_ADMIN_ID=$(gam_user_id "$GW_ID" "$GAM_OWNER2_API_TOKEN" "$E2E_GAM_USER") + GAM_NEW_VIEWER_ID=$(gam_user_id "$GW_ID" "$GAM_OWNER2_API_TOKEN" "$E2E_GAM_ADMIN") + if [[ -z "$GAM_NEW_ADMIN_ID" || -z "$GAM_NEW_VIEWER_ID" ]]; then + fail_test "Access management: owner could not resolve the two users it just added for removal" + else + show_cmd "DELETE .../gateways/${GW_ID}/access/${GAM_NEW_ADMIN_ID} (as owner ${E2E_GAM_OWNER})" + GAM_CODE=$(gam_revoke "$GW_ID" "$GAM_OWNER2_API_TOKEN" "$GAM_NEW_ADMIN_ID") + if [[ "$GAM_CODE" == "204" || "$GAM_CODE" == "200" ]]; then + pass "Granted owner (${E2E_GAM_OWNER}) removed the added admin (${E2E_GAM_USER}) (HTTP ${GAM_CODE})" + else + fail_test "Access management: owner revoke of added admin ${E2E_GAM_USER} returned HTTP ${GAM_CODE:-none}" + dim " ${GAM_LAST_BODY:0:200}" + fi + show_cmd "DELETE .../gateways/${GW_ID}/access/${GAM_NEW_VIEWER_ID} (as owner ${E2E_GAM_OWNER})" + GAM_CODE=$(gam_revoke "$GW_ID" "$GAM_OWNER2_API_TOKEN" "$GAM_NEW_VIEWER_ID") + if [[ "$GAM_CODE" == "204" || "$GAM_CODE" == "200" ]]; then + pass "Granted owner (${E2E_GAM_OWNER}) removed the added viewer (${E2E_GAM_ADMIN}) (HTTP ${GAM_CODE})" + else + fail_test "Access management: owner revoke of added viewer ${E2E_GAM_ADMIN} returned HTTP ${GAM_CODE:-none}" + dim " ${GAM_LAST_BODY:0:200}" + fi + if [[ -z "$(gam_user_id "$GW_ID" "$GAM_OWNER2_API_TOKEN" "$E2E_GAM_USER")" \ + && -z "$(gam_user_id "$GW_ID" "$GAM_OWNER2_API_TOKEN" "$E2E_GAM_ADMIN")" ]]; then + pass "Both users the owner added are removed from the access list" + else + fail_test "Access management: a user removed by the owner still has access" + fi + fi + fi + + # "finally delete the gateway": the owner tier authorizes gateway deletion. This + # is verified on a dedicated throwaway gateway rather than the main one, because + # the main gateway must survive for areas 10-11 (platform-admin delete + the + # namespace-GC assertion). A delete check needs no provisioning wait. + if [[ -n "$GAM_OWNER2_API_TOKEN" ]]; then + GAM_GW_NAME="e2e-gam-gw-$(date +%s | tail -c5)" + GAM_GW_BODY=$(GW_NAME="$GAM_GW_NAME" E2E_OIDC_ISSUER="$E2E_OIDC_ISSUER" \ + E2E_OIDC_CLIENT_ID="$E2E_OIDC_CLIENT_ID" E2E_CLUSTER_ID="${E2E_CLUSTER_ID:-}" python3 -c " +import json, os +print(json.dumps({'name':os.environ['GW_NAME'],'cluster_id':os.environ['E2E_CLUSTER_ID'], + 'oidc':json.dumps({'issuer':os.environ['E2E_OIDC_ISSUER'],'audience':os.environ['E2E_OIDC_CLIENT_ID'], + 'roles_claim':'groups','admin_role':'hypershell-admins','user_role':'hypershell-users'}), + 'route':json.dumps({'enabled':True})}))") + GAM_GW_ID=$(api_curl -X POST "${API_HOST}/api/hypershell/v1/gateways" \ + -H "Content-Type: application/json" -d "$GAM_GW_BODY" 2>/dev/null \ + | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) + if [[ -z "$GAM_GW_ID" ]]; then + fail_test "Access management: failed to create throwaway gateway for the owner-delete check" + else + E2E_EXTRA_GW_IDS+=("$GAM_GW_ID") # fail-safe cleanup if the delete below fails + GAM_CODE=$(gam_grant "$GAM_GW_ID" "$GAM_OWNER_API_TOKEN" "$E2E_GAM_OWNER" owner) + if [[ "$GAM_CODE" =~ ^2 ]]; then + pass "Owner grant on throwaway gateway ${GAM_GW_NAME} (HTTP ${GAM_CODE})" + else + fail_test "Access management: grant owner on throwaway gateway returned HTTP ${GAM_CODE:-none}" + dim " ${GAM_LAST_BODY:0:200}" + fi + show_cmd "DELETE .../gateways/${GAM_GW_ID} (as owner ${E2E_GAM_OWNER})" + GAM_DEL_F=$(mktemp) + GAM_DEL_CODE=$(_driver_curl -o "$GAM_DEL_F" -w '%{http_code}' -X DELETE \ + "${API_HOST}/api/hypershell/v1/gateways/${GAM_GW_ID}" \ + -H "Authorization: Bearer ${GAM_OWNER2_API_TOKEN}" 2>/dev/null || true) + if [[ "$GAM_DEL_CODE" == "204" || "$GAM_DEL_CODE" == "200" ]]; then + pass "Granted owner (${E2E_GAM_OWNER}) deleted the gateway (HTTP ${GAM_DEL_CODE})" + GAM_NEW_EXTRA=(); for _x in "${E2E_EXTRA_GW_IDS[@]}"; do [[ "$_x" != "$GAM_GW_ID" ]] && GAM_NEW_EXTRA+=("$_x"); done + E2E_EXTRA_GW_IDS=("${GAM_NEW_EXTRA[@]}") + else + fail_test "Granted owner (${E2E_GAM_OWNER}): gateway delete returned HTTP ${GAM_DEL_CODE:-none} (want 204)" + dim " $(sed 's/\x1b\[[0-9;]*m//g' "$GAM_DEL_F" 2>/dev/null | tr '\n' ' ' | tr -s ' ' | cut -c1-200)" + fi + rm -f "$GAM_DEL_F" + fi + fi + + acquire_oidc_token 2>/dev/null || true # restore admin token for later areas +fi +sep + # ── 10. platform admin RBAC verification ───────────────────────────────── echo "" From ec2e019e54833f74a0d0fb11748fbfb6c0f58a04 Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Wed, 7 Oct 2026 18:09:52 -0400 Subject: [PATCH 11/23] fix(gateways): gate console delete on per-caller can_delete capability The gateway Delete action was always enabled in the console, even for callers (gateway:viewer / the "user" tier) the API would reject with 403. The Gateway object carried no caller role, so the UI had nothing to gate on. Advertise a read-only, per-caller can_delete on the Gateway REST resource, computed with the same check that authorizes DELETE (gateway:owner on the gateway, or platform:admin). Thread it through the generated SDKs into the console and disable (not hide) the Delete action with an accessible explanation when the caller cannot delete; the API server stays authoritative and still enforces DELETE. Co-Authored-By: Claude Opus 4.8 --- .../api-server/openapi/openapi.gateways.yaml | 4 ++ .../pkg/api/openapi/api/openapi.yaml | 10 ++++ .../pkg/api/openapi/docs/Gateway.md | 26 +++++++++ .../pkg/api/openapi/model_gateway.go | 37 ++++++++++++ .../api-server/pkg/rbac/authorization.go | 9 +++ .../api-server/pkg/rbac/authorization_test.go | 56 +++++++++++++++++++ .../api-server/plugins/gateways/handler.go | 50 +++++++++++++++-- .../api-server/plugins/gateways/plugin.go | 6 +- .../api-server/plugins/gateways/presenter.go | 3 +- components/sdk-go/client/agent_runtime_api.go | 2 +- components/sdk-go/client/client.go | 2 +- .../sdk-go/client/gateway_access_api.go | 2 +- components/sdk-go/client/gateway_api.go | 2 +- .../sdk-go/client/inference_route_api.go | 2 +- components/sdk-go/client/iterator.go | 2 +- .../sdk-go/client/managed_cluster_api.go | 2 +- .../open_shell_gateway_service_account_api.go | 2 +- .../sdk-go/client/provider_binding_api.go | 2 +- components/sdk-go/client/provider_spec_api.go | 2 +- components/sdk-go/client/role_api.go | 2 +- components/sdk-go/client/role_binding_api.go | 2 +- .../sdk-go/client/sandbox_template_api.go | 2 +- components/sdk-go/client/secret_source_api.go | 2 +- components/sdk-go/client/user_api.go | 2 +- components/sdk-go/types/agent_runtime.go | 2 +- components/sdk-go/types/base.go | 2 +- components/sdk-go/types/gateway.go | 3 +- components/sdk-go/types/gateway_access.go | 2 +- components/sdk-go/types/inference_route.go | 2 +- components/sdk-go/types/list_options.go | 2 +- components/sdk-go/types/managed_cluster.go | 2 +- .../open_shell_gateway_service_account.go | 2 +- components/sdk-go/types/provider_binding.go | 2 +- components/sdk-go/types/provider_spec.go | 2 +- components/sdk-go/types/role.go | 2 +- components/sdk-go/types/role_binding.go | 2 +- components/sdk-go/types/sandbox_template.go | 2 +- components/sdk-go/types/secret_source.go | 2 +- components/sdk-go/types/user.go | 2 +- .../sdk-typescript/src/agent_runtime.ts | 2 +- .../sdk-typescript/src/agent_runtime_api.ts | 2 +- components/sdk-typescript/src/base.ts | 2 +- components/sdk-typescript/src/client.ts | 2 +- components/sdk-typescript/src/gateway.ts | 3 +- .../sdk-typescript/src/gateway_access.ts | 2 +- .../sdk-typescript/src/gateway_access_api.ts | 2 +- components/sdk-typescript/src/gateway_api.ts | 2 +- components/sdk-typescript/src/index.ts | 2 +- .../sdk-typescript/src/inference_route.ts | 2 +- .../sdk-typescript/src/inference_route_api.ts | 2 +- .../sdk-typescript/src/managed_cluster.ts | 2 +- .../sdk-typescript/src/managed_cluster_api.ts | 2 +- .../src/open_shell_gateway_service_account.ts | 2 +- .../open_shell_gateway_service_account_api.ts | 2 +- .../sdk-typescript/src/provider_binding.ts | 2 +- .../src/provider_binding_api.ts | 2 +- .../sdk-typescript/src/provider_spec.ts | 2 +- .../sdk-typescript/src/provider_spec_api.ts | 2 +- components/sdk-typescript/src/role.ts | 2 +- components/sdk-typescript/src/role_api.ts | 2 +- components/sdk-typescript/src/role_binding.ts | 2 +- .../sdk-typescript/src/role_binding_api.ts | 2 +- .../sdk-typescript/src/sandbox_template.ts | 2 +- .../src/sandbox_template_api.ts | 2 +- .../sdk-typescript/src/secret_source.ts | 2 +- .../sdk-typescript/src/secret_source_api.ts | 2 +- components/sdk-typescript/src/user.ts | 2 +- components/sdk-typescript/src/user_api.ts | 2 +- .../adapters/api/gateway-operations.test.ts | 1 + .../app/adapters/api/gateway-operations.ts | 3 + components/web-console/locales/en.json | 4 ++ .../src/application/gateway-types.ts | 1 + .../src/gateways/gateway-connections.ts | 1 + .../src/gateways/gateway-data.ts | 3 + .../src/gateways/gateway-detail-header.tsx | 17 ++++++ .../src/gateways/gateway-row-actions.tsx | 17 ++++++ .../gateway-management-ui/src/messages.ts | 7 +++ .../gateway-access-management.spec.md | 11 ++++ specs/web-console/architecture.spec.md | 19 ++++--- 79 files changed, 331 insertions(+), 74 deletions(-) diff --git a/components/api-server/openapi/openapi.gateways.yaml b/components/api-server/openapi/openapi.gateways.yaml index 759aa7d25..c5117ba2b 100644 --- a/components/api-server/openapi/openapi.gateways.yaml +++ b/components/api-server/openapi/openapi.gateways.yaml @@ -325,6 +325,10 @@ components: format: int64 readOnly: true description: Generation the control plane last successfully applied; converged when equal to generation + can_delete: + type: boolean + readOnly: true + description: Whether the authenticated caller is authorized to delete this gateway (gateway owner or platform admin). Advertised so clients can disable a delete affordance the API would reject; the server remains authoritative. GatewayCreateRequest: type: object required: diff --git a/components/api-server/pkg/api/openapi/api/openapi.yaml b/components/api-server/pkg/api/openapi/api/openapi.yaml index 3342fffb8..7fdbe2436 100644 --- a/components/api-server/pkg/api/openapi/api/openapi.yaml +++ b/components/api-server/pkg/api/openapi/api/openapi.yaml @@ -3333,6 +3333,13 @@ components: format: int64 readOnly: true type: integer + can_delete: + description: Whether the authenticated caller is authorized to delete + this gateway (gateway owner or platform admin). Advertised so clients + can disable a delete affordance the API would reject; the server remains + authoritative. + readOnly: true + type: boolean required: - cluster_id - name @@ -3346,6 +3353,7 @@ components: credential_driver: credential_driver cluster_id: cluster_id updated_at: 2000-01-23T04:56:07.000+00:00 + can_delete: true id: id href: href gateway_version: gateway_version @@ -3400,6 +3408,7 @@ components: credential_driver: credential_driver cluster_id: cluster_id updated_at: 2000-01-23T04:56:07.000+00:00 + can_delete: true id: id href: href gateway_version: gateway_version @@ -3435,6 +3444,7 @@ components: credential_driver: credential_driver cluster_id: cluster_id updated_at: 2000-01-23T04:56:07.000+00:00 + can_delete: true id: id href: href gateway_version: gateway_version diff --git a/components/api-server/pkg/api/openapi/docs/Gateway.md b/components/api-server/pkg/api/openapi/docs/Gateway.md index 53a0ffc29..74c4b07af 100644 --- a/components/api-server/pkg/api/openapi/docs/Gateway.md +++ b/components/api-server/pkg/api/openapi/docs/Gateway.md @@ -31,6 +31,7 @@ Name | Type | Description | Notes **CreatedBy** | Pointer to **string** | Username of the user who provisioned this gateway, resolved from RBAC role bindings | [optional] [readonly] **Generation** | Pointer to **int64** | Monotonic desired-state revision, incremented by the API server on any desired-spec change | [optional] [readonly] **ObservedGeneration** | Pointer to **int64** | Generation the control plane last successfully applied; converged when equal to generation | [optional] [readonly] +**CanDelete** | Pointer to **bool** | Whether the authenticated caller is authorized to delete this gateway (gateway owner or platform admin). Advertised so clients can disable a delete affordance the API would reject; the server remains authoritative. | [optional] [readonly] ## Methods @@ -711,6 +712,31 @@ SetObservedGeneration sets ObservedGeneration field to given value. HasObservedGeneration returns a boolean if a field has been set. +### GetCanDelete + +`func (o *Gateway) GetCanDelete() bool` + +GetCanDelete returns the CanDelete field if non-nil, zero value otherwise. + +### GetCanDeleteOk + +`func (o *Gateway) GetCanDeleteOk() (*bool, bool)` + +GetCanDeleteOk returns a tuple with the CanDelete field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetCanDelete + +`func (o *Gateway) SetCanDelete(v bool)` + +SetCanDelete sets CanDelete field to given value. + +### HasCanDelete + +`func (o *Gateway) HasCanDelete() bool` + +HasCanDelete returns a boolean if a field has been set. + [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/components/api-server/pkg/api/openapi/model_gateway.go b/components/api-server/pkg/api/openapi/model_gateway.go index f7aa16de9..0fdf5dcb8 100644 --- a/components/api-server/pkg/api/openapi/model_gateway.go +++ b/components/api-server/pkg/api/openapi/model_gateway.go @@ -64,6 +64,8 @@ type Gateway struct { Generation *int64 `json:"generation,omitempty"` // Generation the control plane last successfully applied; converged when equal to generation ObservedGeneration *int64 `json:"observed_generation,omitempty"` + // Whether the authenticated caller is authorized to delete this gateway (gateway owner or platform admin). Advertised so clients can disable a delete affordance the API would reject; the server remains authoritative. + CanDelete *bool `json:"can_delete,omitempty"` } type _Gateway Gateway @@ -928,6 +930,38 @@ func (o *Gateway) SetObservedGeneration(v int64) { o.ObservedGeneration = &v } +// GetCanDelete returns the CanDelete field value if set, zero value otherwise. +func (o *Gateway) GetCanDelete() bool { + if o == nil || IsNil(o.CanDelete) { + var ret bool + return ret + } + return *o.CanDelete +} + +// GetCanDeleteOk returns a tuple with the CanDelete field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *Gateway) GetCanDeleteOk() (*bool, bool) { + if o == nil || IsNil(o.CanDelete) { + return nil, false + } + return o.CanDelete, true +} + +// HasCanDelete returns a boolean if a field has been set. +func (o *Gateway) HasCanDelete() bool { + if o != nil && !IsNil(o.CanDelete) { + return true + } + + return false +} + +// SetCanDelete gets a reference to the given bool and assigns it to the CanDelete field. +func (o *Gateway) SetCanDelete(v bool) { + o.CanDelete = &v +} + func (o Gateway) MarshalJSON() ([]byte, error) { toSerialize, err := o.ToMap() if err != nil { @@ -1013,6 +1047,9 @@ func (o Gateway) ToMap() (map[string]interface{}, error) { if !IsNil(o.ObservedGeneration) { toSerialize["observed_generation"] = o.ObservedGeneration } + if !IsNil(o.CanDelete) { + toSerialize["can_delete"] = o.CanDelete + } return toSerialize, nil } diff --git a/components/api-server/pkg/rbac/authorization.go b/components/api-server/pkg/rbac/authorization.go index 2e3bbb36d..2885375ad 100644 --- a/components/api-server/pkg/rbac/authorization.go +++ b/components/api-server/pkg/rbac/authorization.go @@ -442,6 +442,15 @@ func isAuthorized(method string, resource string, resourceID string, gatewayID s return hasGatewayCreator(bindings) } +// CanDeleteGateway reports whether a caller holding bindings may DELETE the +// gateway. It is the single source of truth shared by the DELETE authorization +// middleware (isGatewayAuthorized) and the Gateway.can_delete capability the REST +// API advertises, so a client's delete affordance never diverges from the check +// the server actually enforces. +func CanDeleteGateway(bindings []BindingSummary, gatewayID string) bool { + return isGatewayAuthorized(http.MethodDelete, gatewayID, bindings) +} + func isGatewayAuthorized(method string, gatewayID string, bindings []BindingSummary) bool { if hasPlatformAdmin(bindings) && (method == http.MethodGet || method == http.MethodDelete) { return true diff --git a/components/api-server/pkg/rbac/authorization_test.go b/components/api-server/pkg/rbac/authorization_test.go index e718296c2..897524c5f 100644 --- a/components/api-server/pkg/rbac/authorization_test.go +++ b/components/api-server/pkg/rbac/authorization_test.go @@ -119,6 +119,62 @@ func TestIsAuthorized_GatewayViewerCannotMutateGateway(t *testing.T) { } } +// TestCanDeleteGateway covers the Gateway.can_delete capability the REST API +// advertises to clients. It must match isGatewayAuthorized(DELETE, ...) exactly, +// including the case from the bug report: an owner who demotes their own access +// to the "user" tier (gateway:viewer) can no longer delete, while a platform +// admin retains the delete override. +func TestCanDeleteGateway(t *testing.T) { + gwID := "gw-aaa" + otherID := "gw-bbb" + ptr := func(s string) *string { return &s } + + tests := []struct { + name string + bindings []BindingSummary + want bool + }{ + { + name: "owner can delete", + bindings: []BindingSummary{{RoleName: "gateway:owner", Scope: "gateway", GatewayID: ptr(gwID)}}, + want: true, + }, + { + name: "demoted owner now viewer cannot delete", + bindings: []BindingSummary{{RoleName: "gateway:viewer", Scope: "gateway", GatewayID: ptr(gwID)}}, + want: false, + }, + { + name: "granted admin cannot delete", + bindings: []BindingSummary{{RoleName: "gateway:admin", Scope: "gateway", GatewayID: ptr(gwID)}}, + want: false, + }, + { + name: "platform admin can delete any gateway", + bindings: []BindingSummary{{RoleName: "platform:admin", Scope: "global"}}, + want: true, + }, + { + name: "owner of a different gateway cannot delete", + bindings: []BindingSummary{{RoleName: "gateway:owner", Scope: "gateway", GatewayID: ptr(otherID)}}, + want: false, + }, + { + name: "no bindings cannot delete", + bindings: nil, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := CanDeleteGateway(tc.bindings, gwID); got != tc.want { + t.Errorf("CanDeleteGateway() = %v, want %v", got, tc.want) + } + }) + } +} + func TestIsAuthorized_NonCreatorCannotCreateGateways(t *testing.T) { gwID := "gw-aaa" bindings := []BindingSummary{ diff --git a/components/api-server/plugins/gateways/handler.go b/components/api-server/plugins/gateways/handler.go index 8b1e2a71c..8a050b27b 100644 --- a/components/api-server/plugins/gateways/handler.go +++ b/components/api-server/plugins/gateways/handler.go @@ -39,7 +39,40 @@ type gatewayHandler struct { ownerBinding OwnerBindingCreator visibilityFilter GatewayVisibilityFilter ownerLookup GatewayOwnerLookup + bindingLookup rbac.RoleBindingLookup clusters RegisteredClusterLookup + enforceRBAC bool +} + +// callerBindings loads the current caller's role bindings once, or nil when RBAC +// is not enforced, no lookup is wired, or the caller is unidentified. Errors +// degrade to nil, which is fail-closed for the can_delete capability: a gateway +// the caller cannot be shown to own is reported as not deletable. +func (h gatewayHandler) callerBindings(ctx context.Context) []rbac.BindingSummary { + if !h.enforceRBAC || h.bindingLookup == nil { + return nil + } + userID := rbac.GetUserIDFromContext(ctx) + if userID == "" { + return nil + } + bindings, err := h.bindingLookup.FindBindingsByUserID(ctx, userID) + if err != nil { + return nil + } + return bindings +} + +// callerCanDelete reports whether the caller holding bindings may DELETE the +// gateway, mirroring the RBAC middleware's enforced check exactly +// (rbac.CanDeleteGateway). Advertised as Gateway.can_delete so a client can +// disable a delete affordance the server would reject. When RBAC is not enforced +// the server permits every delete, so it reports true. +func (h gatewayHandler) callerCanDelete(bindings []rbac.BindingSummary, gatewayID string) bool { + if !h.enforceRBAC { + return true + } + return rbac.CanDeleteGateway(bindings, gatewayID) } // validateGatewayPhaseValue rejects a phase outside the canonical vocabulary. An @@ -54,14 +87,16 @@ func validateGatewayPhaseValue(phase *string) *errors.ServiceError { return nil } -func NewGatewayHandler(gateway GatewayService, generic services.GenericService, ownerBinding OwnerBindingCreator, visibilityFilter GatewayVisibilityFilter, ownerLookup GatewayOwnerLookup, clusters RegisteredClusterLookup) *gatewayHandler { +func NewGatewayHandler(gateway GatewayService, generic services.GenericService, ownerBinding OwnerBindingCreator, visibilityFilter GatewayVisibilityFilter, ownerLookup GatewayOwnerLookup, bindingLookup rbac.RoleBindingLookup, clusters RegisteredClusterLookup, enforceRBAC bool) *gatewayHandler { return &gatewayHandler{ gateway: gateway, generic: generic, ownerBinding: ownerBinding, visibilityFilter: visibilityFilter, ownerLookup: ownerLookup, + bindingLookup: bindingLookup, clusters: clusters, + enforceRBAC: enforceRBAC, } } @@ -91,7 +126,9 @@ func (h gatewayHandler) Create(w http.ResponseWriter, r *http.Request) { } } - return PresentGateway(gatewayModel, ""), nil + // The creator just received a gateway:owner binding (or RBAC is off), so + // they can always delete what they just created. + return PresentGateway(gatewayModel, "", true), nil }, ErrorHandler: handlers.HandleError, } @@ -184,7 +221,8 @@ func (h gatewayHandler) Patch(w http.ResponseWriter, r *http.Request) { if err != nil { return nil, err } - return PresentGateway(gatewayModel, ""), nil + bindings := h.callerBindings(ctx) + return PresentGateway(gatewayModel, "", h.callerCanDelete(bindings, gatewayModel.ID)), nil }, ErrorHandler: handlers.HandleError, } @@ -255,8 +293,9 @@ func (h gatewayHandler) List(w http.ResponseWriter, r *http.Request) { ownerUsernames = owners } } + bindings := h.callerBindings(ctx) for _, gateway := range gateways { - converted := PresentGateway(&gateway, ownerUsernames[gateway.ID]) + converted := PresentGateway(&gateway, ownerUsernames[gateway.ID], h.callerCanDelete(bindings, gateway.ID)) gatewayList.Items = append(gatewayList.Items, converted) } if listArgs.Fields != nil { @@ -298,7 +337,8 @@ func (h gatewayHandler) Get(w http.ResponseWriter, r *http.Request) { gateway.ID, gateway.Name, username, userID) } - return PresentGateway(gateway, ""), nil + bindings := h.callerBindings(ctx) + return PresentGateway(gateway, "", h.callerCanDelete(bindings, gateway.ID)), nil }, } diff --git a/components/api-server/plugins/gateways/plugin.go b/components/api-server/plugins/gateways/plugin.go index 0128e3c2a..f1032b3a9 100644 --- a/components/api-server/plugins/gateways/plugin.go +++ b/components/api-server/plugins/gateways/plugin.go @@ -3,6 +3,7 @@ package gateways import ( "context" "net/http" + "os" "github.com/gorilla/mux" "google.golang.org/grpc" @@ -120,6 +121,7 @@ func init() { var ownerBinding OwnerBindingCreator var visibilityFilter GatewayVisibilityFilter var ownerLookup GatewayOwnerLookup + var bindingLookup rbac.RoleBindingLookup rbService := roleBindings.Service(envServices) if rbService != nil { ownerBinding = rbac.NewGatewayBootstrapper(rbService) @@ -131,8 +133,10 @@ func init() { return ids, nil }) ownerLookup = rbService + bindingLookup = rbService } - gatewayHandler := NewGatewayHandler(Service(envServices), listService(envServices), ownerBinding, visibilityFilter, ownerLookup, registeredClusterLookup(envServices)) + enforceRBAC := os.Getenv("RBAC_ENFORCE") == "true" + gatewayHandler := NewGatewayHandler(Service(envServices), listService(envServices), ownerBinding, visibilityFilter, ownerLookup, bindingLookup, registeredClusterLookup(envServices), enforceRBAC) gatewaysRouter := apiV1Router.PathPrefix("/gateways").Subrouter() gatewaysRouter.HandleFunc("", gatewayHandler.List).Methods(http.MethodGet) diff --git a/components/api-server/plugins/gateways/presenter.go b/components/api-server/plugins/gateways/presenter.go index a94da0182..b7f09da39 100644 --- a/components/api-server/plugins/gateways/presenter.go +++ b/components/api-server/plugins/gateways/presenter.go @@ -32,9 +32,10 @@ func ConvertGateway(gateway openapi.GatewayCreateRequest) *Gateway { return c } -func PresentGateway(gateway *Gateway, createdBy string) openapi.Gateway { +func PresentGateway(gateway *Gateway, createdBy string, canDelete bool) openapi.Gateway { reference := presenters.PresentReference(gateway.ID, gateway) g := openapi.Gateway{ + CanDelete: openapi.PtrBool(canDelete), Id: reference.Id, Kind: reference.Kind, Href: reference.Href, diff --git a/components/sdk-go/client/agent_runtime_api.go b/components/sdk-go/client/agent_runtime_api.go index e1ab2de0d..a3b304d66 100644 --- a/components/sdk-go/client/agent_runtime_api.go +++ b/components/sdk-go/client/agent_runtime_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package client diff --git a/components/sdk-go/client/client.go b/components/sdk-go/client/client.go index 37127b658..3c318eaa5 100644 --- a/components/sdk-go/client/client.go +++ b/components/sdk-go/client/client.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package client diff --git a/components/sdk-go/client/gateway_access_api.go b/components/sdk-go/client/gateway_access_api.go index b4141cdb9..740dabea1 100644 --- a/components/sdk-go/client/gateway_access_api.go +++ b/components/sdk-go/client/gateway_access_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package client diff --git a/components/sdk-go/client/gateway_api.go b/components/sdk-go/client/gateway_api.go index a9a016b8c..442509107 100644 --- a/components/sdk-go/client/gateway_api.go +++ b/components/sdk-go/client/gateway_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package client diff --git a/components/sdk-go/client/inference_route_api.go b/components/sdk-go/client/inference_route_api.go index 5455186d3..29b70e99a 100644 --- a/components/sdk-go/client/inference_route_api.go +++ b/components/sdk-go/client/inference_route_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package client diff --git a/components/sdk-go/client/iterator.go b/components/sdk-go/client/iterator.go index d91473d56..e8f80ae30 100644 --- a/components/sdk-go/client/iterator.go +++ b/components/sdk-go/client/iterator.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package client diff --git a/components/sdk-go/client/managed_cluster_api.go b/components/sdk-go/client/managed_cluster_api.go index 849f2b909..8d4a89e51 100644 --- a/components/sdk-go/client/managed_cluster_api.go +++ b/components/sdk-go/client/managed_cluster_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package client diff --git a/components/sdk-go/client/open_shell_gateway_service_account_api.go b/components/sdk-go/client/open_shell_gateway_service_account_api.go index b3e8139aa..e28933a1d 100644 --- a/components/sdk-go/client/open_shell_gateway_service_account_api.go +++ b/components/sdk-go/client/open_shell_gateway_service_account_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package client diff --git a/components/sdk-go/client/provider_binding_api.go b/components/sdk-go/client/provider_binding_api.go index e61e6239d..713fc00c8 100644 --- a/components/sdk-go/client/provider_binding_api.go +++ b/components/sdk-go/client/provider_binding_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package client diff --git a/components/sdk-go/client/provider_spec_api.go b/components/sdk-go/client/provider_spec_api.go index df89aa88a..9f1e8edf0 100644 --- a/components/sdk-go/client/provider_spec_api.go +++ b/components/sdk-go/client/provider_spec_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package client diff --git a/components/sdk-go/client/role_api.go b/components/sdk-go/client/role_api.go index 60b34d741..e52566864 100644 --- a/components/sdk-go/client/role_api.go +++ b/components/sdk-go/client/role_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package client diff --git a/components/sdk-go/client/role_binding_api.go b/components/sdk-go/client/role_binding_api.go index e66d783fc..a45fc5a71 100644 --- a/components/sdk-go/client/role_binding_api.go +++ b/components/sdk-go/client/role_binding_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package client diff --git a/components/sdk-go/client/sandbox_template_api.go b/components/sdk-go/client/sandbox_template_api.go index 2da0ebec3..a9a9c6496 100644 --- a/components/sdk-go/client/sandbox_template_api.go +++ b/components/sdk-go/client/sandbox_template_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package client diff --git a/components/sdk-go/client/secret_source_api.go b/components/sdk-go/client/secret_source_api.go index 063e01ba4..60d009bd9 100644 --- a/components/sdk-go/client/secret_source_api.go +++ b/components/sdk-go/client/secret_source_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package client diff --git a/components/sdk-go/client/user_api.go b/components/sdk-go/client/user_api.go index 6320a9187..aae430f77 100644 --- a/components/sdk-go/client/user_api.go +++ b/components/sdk-go/client/user_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package client diff --git a/components/sdk-go/types/agent_runtime.go b/components/sdk-go/types/agent_runtime.go index e5c3f8c14..c663532e1 100644 --- a/components/sdk-go/types/agent_runtime.go +++ b/components/sdk-go/types/agent_runtime.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package types diff --git a/components/sdk-go/types/base.go b/components/sdk-go/types/base.go index 29c5ff32d..ac47fc893 100644 --- a/components/sdk-go/types/base.go +++ b/components/sdk-go/types/base.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package types diff --git a/components/sdk-go/types/gateway.go b/components/sdk-go/types/gateway.go index 86448111b..8dc102214 100644 --- a/components/sdk-go/types/gateway.go +++ b/components/sdk-go/types/gateway.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package types @@ -13,6 +13,7 @@ type Gateway struct { ObjectReference ActiveSandboxCount int32 `json:"active_sandbox_count,omitempty"` + CanDelete bool `json:"can_delete,omitempty"` ClusterID string `json:"cluster_id"` ConsoleAddress string `json:"console_address,omitempty"` CreatedBy string `json:"created_by,omitempty"` diff --git a/components/sdk-go/types/gateway_access.go b/components/sdk-go/types/gateway_access.go index 8f0b66384..42bc49431 100644 --- a/components/sdk-go/types/gateway_access.go +++ b/components/sdk-go/types/gateway_access.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package types diff --git a/components/sdk-go/types/inference_route.go b/components/sdk-go/types/inference_route.go index f800f2fca..b3a154340 100644 --- a/components/sdk-go/types/inference_route.go +++ b/components/sdk-go/types/inference_route.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package types diff --git a/components/sdk-go/types/list_options.go b/components/sdk-go/types/list_options.go index f6ea8e7d0..8790d08fe 100644 --- a/components/sdk-go/types/list_options.go +++ b/components/sdk-go/types/list_options.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package types diff --git a/components/sdk-go/types/managed_cluster.go b/components/sdk-go/types/managed_cluster.go index 1f3a09d94..2cfaf9648 100644 --- a/components/sdk-go/types/managed_cluster.go +++ b/components/sdk-go/types/managed_cluster.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package types diff --git a/components/sdk-go/types/open_shell_gateway_service_account.go b/components/sdk-go/types/open_shell_gateway_service_account.go index e37100873..ff9769a1e 100644 --- a/components/sdk-go/types/open_shell_gateway_service_account.go +++ b/components/sdk-go/types/open_shell_gateway_service_account.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package types diff --git a/components/sdk-go/types/provider_binding.go b/components/sdk-go/types/provider_binding.go index bab9f5f5d..c0a56e00d 100644 --- a/components/sdk-go/types/provider_binding.go +++ b/components/sdk-go/types/provider_binding.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package types diff --git a/components/sdk-go/types/provider_spec.go b/components/sdk-go/types/provider_spec.go index 22a91a288..59b24e043 100644 --- a/components/sdk-go/types/provider_spec.go +++ b/components/sdk-go/types/provider_spec.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package types diff --git a/components/sdk-go/types/role.go b/components/sdk-go/types/role.go index 84bd255a7..af4022871 100644 --- a/components/sdk-go/types/role.go +++ b/components/sdk-go/types/role.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package types diff --git a/components/sdk-go/types/role_binding.go b/components/sdk-go/types/role_binding.go index fdafeb6a5..abd44f7dd 100644 --- a/components/sdk-go/types/role_binding.go +++ b/components/sdk-go/types/role_binding.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package types diff --git a/components/sdk-go/types/sandbox_template.go b/components/sdk-go/types/sandbox_template.go index 532eb41ee..d15063be8 100644 --- a/components/sdk-go/types/sandbox_template.go +++ b/components/sdk-go/types/sandbox_template.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package types diff --git a/components/sdk-go/types/secret_source.go b/components/sdk-go/types/secret_source.go index dfafba804..41de72e9a 100644 --- a/components/sdk-go/types/secret_source.go +++ b/components/sdk-go/types/secret_source.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package types diff --git a/components/sdk-go/types/user.go b/components/sdk-go/types/user.go index d4d8e060f..0b60aac72 100644 --- a/components/sdk-go/types/user.go +++ b/components/sdk-go/types/user.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 package types diff --git a/components/sdk-typescript/src/agent_runtime.ts b/components/sdk-typescript/src/agent_runtime.ts index ceda046a3..5e4720278 100644 --- a/components/sdk-typescript/src/agent_runtime.ts +++ b/components/sdk-typescript/src/agent_runtime.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/agent_runtime_api.ts b/components/sdk-typescript/src/agent_runtime_api.ts index ef6710f6a..84d402aff 100644 --- a/components/sdk-typescript/src/agent_runtime_api.ts +++ b/components/sdk-typescript/src/agent_runtime_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/base.ts b/components/sdk-typescript/src/base.ts index 529e6a68f..7abd507b5 100644 --- a/components/sdk-typescript/src/base.ts +++ b/components/sdk-typescript/src/base.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 export type ObjectReference = { id: string; diff --git a/components/sdk-typescript/src/client.ts b/components/sdk-typescript/src/client.ts index d85b3d2df..7c23367d9 100644 --- a/components/sdk-typescript/src/client.ts +++ b/components/sdk-typescript/src/client.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { SDKClientConfig } from './base.js'; import { AgentRuntimeAPI } from './agent_runtime_api.js'; diff --git a/components/sdk-typescript/src/gateway.ts b/components/sdk-typescript/src/gateway.ts index 43b6c3aef..10fc4e43e 100644 --- a/components/sdk-typescript/src/gateway.ts +++ b/components/sdk-typescript/src/gateway.ts @@ -1,11 +1,12 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { ObjectReference, ListMeta } from './base.js'; export type Gateway = ObjectReference & { active_sandbox_count: number; + can_delete: boolean; cluster_id: string; console_address: string; created_by: string; diff --git a/components/sdk-typescript/src/gateway_access.ts b/components/sdk-typescript/src/gateway_access.ts index 3925ee9c7..f4208d6b4 100644 --- a/components/sdk-typescript/src/gateway_access.ts +++ b/components/sdk-typescript/src/gateway_access.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 export type GatewayAccessCapabilities = { diff --git a/components/sdk-typescript/src/gateway_access_api.ts b/components/sdk-typescript/src/gateway_access_api.ts index 66b7da912..312e16fdb 100644 --- a/components/sdk-typescript/src/gateway_access_api.ts +++ b/components/sdk-typescript/src/gateway_access_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { SDKClientConfig, RequestOptions } from './base.js'; import { sdkFetch } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_api.ts b/components/sdk-typescript/src/gateway_api.ts index b31b6ab13..4d468c2da 100644 --- a/components/sdk-typescript/src/gateway_api.ts +++ b/components/sdk-typescript/src/gateway_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/index.ts b/components/sdk-typescript/src/index.ts index e56a0c2e9..d51632edb 100644 --- a/components/sdk-typescript/src/index.ts +++ b/components/sdk-typescript/src/index.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 export { SDKClient } from './client.js'; export type { SDKClientConfig, ListOptions, RequestOptions, ObjectReference, ListMeta, APIError } from './base.js'; diff --git a/components/sdk-typescript/src/inference_route.ts b/components/sdk-typescript/src/inference_route.ts index ae624a4f7..d0a261577 100644 --- a/components/sdk-typescript/src/inference_route.ts +++ b/components/sdk-typescript/src/inference_route.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/inference_route_api.ts b/components/sdk-typescript/src/inference_route_api.ts index 9b95bae27..7c74bf155 100644 --- a/components/sdk-typescript/src/inference_route_api.ts +++ b/components/sdk-typescript/src/inference_route_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/managed_cluster.ts b/components/sdk-typescript/src/managed_cluster.ts index 07bfbaaed..e50bf5ddb 100644 --- a/components/sdk-typescript/src/managed_cluster.ts +++ b/components/sdk-typescript/src/managed_cluster.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/managed_cluster_api.ts b/components/sdk-typescript/src/managed_cluster_api.ts index fe1967fdc..d5bd0e6ee 100644 --- a/components/sdk-typescript/src/managed_cluster_api.ts +++ b/components/sdk-typescript/src/managed_cluster_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/open_shell_gateway_service_account.ts b/components/sdk-typescript/src/open_shell_gateway_service_account.ts index f5df03635..6d1ad7758 100644 --- a/components/sdk-typescript/src/open_shell_gateway_service_account.ts +++ b/components/sdk-typescript/src/open_shell_gateway_service_account.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 export type OpenShellGatewayServiceAccountCapabilities = { diff --git a/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts b/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts index 396bf7693..5be8a4716 100644 --- a/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts +++ b/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { SDKClientConfig, RequestOptions } from './base.js'; import { sdkFetch } from './base.js'; diff --git a/components/sdk-typescript/src/provider_binding.ts b/components/sdk-typescript/src/provider_binding.ts index 3064d44b4..5fb88b316 100644 --- a/components/sdk-typescript/src/provider_binding.ts +++ b/components/sdk-typescript/src/provider_binding.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/provider_binding_api.ts b/components/sdk-typescript/src/provider_binding_api.ts index 97a032f7f..391421e0d 100644 --- a/components/sdk-typescript/src/provider_binding_api.ts +++ b/components/sdk-typescript/src/provider_binding_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/provider_spec.ts b/components/sdk-typescript/src/provider_spec.ts index 4adcf7536..cbd24e35a 100644 --- a/components/sdk-typescript/src/provider_spec.ts +++ b/components/sdk-typescript/src/provider_spec.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/provider_spec_api.ts b/components/sdk-typescript/src/provider_spec_api.ts index 3324f4a9c..0345282ea 100644 --- a/components/sdk-typescript/src/provider_spec_api.ts +++ b/components/sdk-typescript/src/provider_spec_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/role.ts b/components/sdk-typescript/src/role.ts index b786bbeb3..8aea439d2 100644 --- a/components/sdk-typescript/src/role.ts +++ b/components/sdk-typescript/src/role.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/role_api.ts b/components/sdk-typescript/src/role_api.ts index 61e8204fb..a3ea2138d 100644 --- a/components/sdk-typescript/src/role_api.ts +++ b/components/sdk-typescript/src/role_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/role_binding.ts b/components/sdk-typescript/src/role_binding.ts index 07448b496..49bd4ea3a 100644 --- a/components/sdk-typescript/src/role_binding.ts +++ b/components/sdk-typescript/src/role_binding.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/role_binding_api.ts b/components/sdk-typescript/src/role_binding_api.ts index bebdae413..26deccd44 100644 --- a/components/sdk-typescript/src/role_binding_api.ts +++ b/components/sdk-typescript/src/role_binding_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/sandbox_template.ts b/components/sdk-typescript/src/sandbox_template.ts index e6f979a97..bb3426619 100644 --- a/components/sdk-typescript/src/sandbox_template.ts +++ b/components/sdk-typescript/src/sandbox_template.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/sandbox_template_api.ts b/components/sdk-typescript/src/sandbox_template_api.ts index 83cddbae6..ebf6764e1 100644 --- a/components/sdk-typescript/src/sandbox_template_api.ts +++ b/components/sdk-typescript/src/sandbox_template_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/secret_source.ts b/components/sdk-typescript/src/secret_source.ts index 2a569d52a..79c8c04d8 100644 --- a/components/sdk-typescript/src/secret_source.ts +++ b/components/sdk-typescript/src/secret_source.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/secret_source_api.ts b/components/sdk-typescript/src/secret_source_api.ts index 75bcf2665..23c23cd82 100644 --- a/components/sdk-typescript/src/secret_source_api.ts +++ b/components/sdk-typescript/src/secret_source_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/user.ts b/components/sdk-typescript/src/user.ts index bc10f8e28..c38e3724b 100644 --- a/components/sdk-typescript/src/user.ts +++ b/components/sdk-typescript/src/user.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/user_api.ts b/components/sdk-typescript/src/user_api.ts index 9e3379c50..50c74043d 100644 --- a/components/sdk-typescript/src/user_api.ts +++ b/components/sdk-typescript/src/user_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: e8f024e88b195e2a32ea7e42534fc438e250c5f4b9f11e7ea9c9e802488c74c8 +// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/web-console/app/adapters/api/gateway-operations.test.ts b/components/web-console/app/adapters/api/gateway-operations.test.ts index be8bc12b3..0df15c581 100644 --- a/components/web-console/app/adapters/api/gateway-operations.test.ts +++ b/components/web-console/app/adapters/api/gateway-operations.test.ts @@ -59,6 +59,7 @@ const listRequest = { function gateway(overrides: Partial = {}): Gateway { return { active_sandbox_count: 0, + can_delete: false, cluster_id: "", console_address: "", created_at: null, diff --git a/components/web-console/app/adapters/api/gateway-operations.ts b/components/web-console/app/adapters/api/gateway-operations.ts index b4c3a19e0..f51cc8e58 100644 --- a/components/web-console/app/adapters/api/gateway-operations.ts +++ b/components/web-console/app/adapters/api/gateway-operations.ts @@ -181,6 +181,9 @@ function toGatewayRecord(gateway: Gateway): GatewayRecord { return { ...(activeSandboxCount !== undefined ? { activeSandboxCount } : {}), + ...(typeof gateway.can_delete === "boolean" + ? { canDelete: gateway.can_delete } + : {}), clusterId: gateway.cluster_id, ...(consoleUrl ? { consoleUrl } : {}), ...(gateway.created_at ? { createdAt: gateway.created_at } : {}), diff --git a/components/web-console/locales/en.json b/components/web-console/locales/en.json index 140d9a998..b5184f0db 100644 --- a/components/web-console/locales/en.json +++ b/components/web-console/locales/en.json @@ -1127,6 +1127,10 @@ "defaultMessage": "Gateway could not be deleted", "description": "Title shown when gateway deletion fails." }, + "app.gateway.delete.notPermitted": { + "defaultMessage": "Only a gateway owner or platform admin can delete this gateway.", + "description": "Tooltip explaining why the Delete gateway action is disabled for a caller who lacks delete permission." + }, "app.gateway.delete.pending": { "defaultMessage": "Deleting gateway", "description": "Accessible progress text while a gateway is being deleted." diff --git a/packages/gateway-management-ui/src/application/gateway-types.ts b/packages/gateway-management-ui/src/application/gateway-types.ts index e41707779..66deb8627 100644 --- a/packages/gateway-management-ui/src/application/gateway-types.ts +++ b/packages/gateway-management-ui/src/application/gateway-types.ts @@ -9,6 +9,7 @@ export interface ProvisioningCondition { export interface GatewayRecord { activeSandboxCount?: number; + canDelete?: boolean; clusterId: string; consoleUrl?: string; createdAt?: string; diff --git a/packages/gateway-management-ui/src/gateways/gateway-connections.ts b/packages/gateway-management-ui/src/gateways/gateway-connections.ts index 75c5b2f26..a2e9c22ae 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-connections.ts +++ b/packages/gateway-management-ui/src/gateways/gateway-connections.ts @@ -2,6 +2,7 @@ import { gatewayCanonicalPhases } from "./gateway-data"; export interface GatewayConnection { activeSandboxCount?: number; + canDelete?: boolean; clusterId: string; clusterName: string; consoleUrl?: string; diff --git a/packages/gateway-management-ui/src/gateways/gateway-data.ts b/packages/gateway-management-ui/src/gateways/gateway-data.ts index 363625fac..648d7410e 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-data.ts +++ b/packages/gateway-management-ui/src/gateways/gateway-data.ts @@ -378,6 +378,9 @@ export function toGatewayConnection( ...(typeof gateway.activeSandboxCount === "number" ? { activeSandboxCount: gateway.activeSandboxCount } : {}), + ...(typeof gateway.canDelete === "boolean" + ? { canDelete: gateway.canDelete } + : {}), clusterId, // The name is resolved from the ManagedCluster the id references. clusterName: "", diff --git a/packages/gateway-management-ui/src/gateways/gateway-detail-header.tsx b/packages/gateway-management-ui/src/gateways/gateway-detail-header.tsx index 35a52f0b8..3367932db 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-detail-header.tsx +++ b/packages/gateway-management-ui/src/gateways/gateway-detail-header.tsx @@ -119,6 +119,10 @@ function GatewayDetailActions({ const [isActionsOpen, setIsActionsOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isRenameOpen, setIsRenameOpen] = useState(false); + // Disable delete only when the server has explicitly told us the caller cannot + // delete. An absent capability (older server) leaves it enabled; the API 403 + // remains authoritative either way. + const canDelete = gateway.canDelete !== false; return ( <> @@ -196,10 +200,23 @@ function GatewayDetailActions({ { + if (!canDelete) { + return; + } setIsDeleteOpen(true); }} + tooltipProps={ + canDelete + ? undefined + : { + content: intl.formatMessage( + messages.deleteGatewayNotPermitted, + ), + } + } > diff --git a/packages/gateway-management-ui/src/gateways/gateway-row-actions.tsx b/packages/gateway-management-ui/src/gateways/gateway-row-actions.tsx index 93ee8b629..e364d6e22 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-row-actions.tsx +++ b/packages/gateway-management-ui/src/gateways/gateway-row-actions.tsx @@ -35,6 +35,10 @@ export function GatewayRowActions({ const [isRenameOpen, setIsRenameOpen] = useState(false); const [copyResult, setCopyResult] = useState<"error" | "success">(); const connectionCommand = buildGatewayAddCommand(gateway); + // Disable delete only when the server has explicitly told us the caller cannot + // delete. An absent capability (older server) leaves it enabled; the API 403 + // remains authoritative either way. + const canDelete = gateway.canDelete !== false; const copyConnectionCommand = async () => { if (!connectionCommand) { @@ -102,10 +106,23 @@ export function GatewayRowActions({ { + if (!canDelete) { + return; + } setIsDeleteOpen(true); }} + tooltipProps={ + canDelete + ? undefined + : { + content: intl.formatMessage( + messages.deleteGatewayNotPermitted, + ), + } + } > diff --git a/packages/gateway-management-ui/src/messages.ts b/packages/gateway-management-ui/src/messages.ts index 5bba46daa..11e0a42b1 100644 --- a/packages/gateway-management-ui/src/messages.ts +++ b/packages/gateway-management-ui/src/messages.ts @@ -265,6 +265,13 @@ export const messages = defineMessages({ "Deleting {gatewayName} will permanently remove the gateway. This action cannot be undone.", description: "Warning shown before permanently deleting a gateway.", }, + deleteGatewayNotPermitted: { + id: "app.gateway.delete.notPermitted", + defaultMessage: + "Only a gateway owner or platform admin can delete this gateway.", + description: + "Tooltip explaining why the Delete gateway action is disabled for a caller who lacks delete permission.", + }, deleteGatewayTitle: { id: "app.gateway.delete.title", defaultMessage: "Delete {gatewayName}?", diff --git a/specs/platform/gateway-access-management.spec.md b/specs/platform/gateway-access-management.spec.md index 95d9d4f44..e9576dafc 100644 --- a/specs/platform/gateway-access-management.spec.md +++ b/specs/platform/gateway-access-management.spec.md @@ -55,6 +55,8 @@ The platform SHALL define a per-gateway built-in role `gateway:admin` representi `gateway:admin` SHALL NOT delete the gateway, and SHALL NOT grant, change, or revoke the Owner tier (`gateway:owner`). Deleting a gateway SHALL require `gateway:owner` or `platform:admin`; assigning an owner SHALL require `gateway:owner` (GAM-08). +The Gateway REST resource SHALL advertise a read-only, per-caller `can_delete` boolean on `GET` (list and single) responses, computed from the authenticated caller's current bindings with the **same check that authorizes `DELETE`** (`gateway:owner` on that gateway, or `platform:admin`). `can_delete` is advisory: it exists so clients (console, CLI) can disable a delete affordance the API would reject, and SHALL NOT weaken enforcement. The API server SHALL remain the authorization boundary and SHALL enforce delete authorization on `DELETE` regardless of `can_delete`. When RBAC enforcement is disabled, every delete is permitted, so `can_delete` SHALL be `true`. + `gateway:admin` SHALL be stored and granted exactly like other per-gateway bindings: a `RoleBinding` with `scope=gateway` and a `gateway_id`. It SHALL NOT be a JWT-synced role and SHALL NOT be assignable via Keycloak realm roles. A user holding `gateway:admin` on a gateway SHALL see that gateway in list and get results (gateway visibility follows any per-gateway binding). @@ -73,6 +75,15 @@ A user holding `gateway:admin` on a gateway SHALL see that gateway in list and g - WHEN user B calls `GET /api/hypershell/v1/gateways` - THEN the response SHALL include gw-1 +#### Scenario: can_delete reflects the caller's delete authorization + +- GIVEN user A demoted their own access on gw-1 from `gateway:owner` to the User tier (`gateway:viewer`), and user C remains an owner +- WHEN user A calls `GET /api/hypershell/v1/gateways/gw-1` +- THEN the response `can_delete` SHALL be `false` +- AND a `DELETE /api/hypershell/v1/gateways/gw-1` by user A SHALL return `403` +- WHEN user C (a `gateway:owner`) or a `platform:admin` calls `GET /api/hypershell/v1/gateways/gw-1` +- THEN the response `can_delete` SHALL be `true` + --- ### Requirement: GAM-02 -- Keycloak Role Bridge Mapping diff --git a/specs/web-console/architecture.spec.md b/specs/web-console/architecture.spec.md index 372c82acb..7457b6744 100644 --- a/specs/web-console/architecture.spec.md +++ b/specs/web-console/architecture.spec.md @@ -352,6 +352,8 @@ Gateway renaming SHALL use the existing `PATCH /gateways/{id}` contract and send Gateway deletion SHALL use the existing `DELETE /gateways/{id}` contract. Both delete entry points SHALL present the same explicit confirmation before commitment, prevent duplicate submission while deletion is pending, preserve the confirmation with recovery guidance on failure, and provide an accurate success notification after deletion. Successful deletion from a detail page SHALL return the user to the gateway collection and invalidate both collection and deleted-detail query state. +Both delete entry points (the row actions menu and the detail header Actions dropdown) SHALL disable the delete action -- rather than hide it -- with an accessible explanation when the gateway's advertised `can_delete` capability is false (the caller is neither a `gateway:owner` nor a `platform:admin`; see platform `gateway-access-management.spec.md` GAM-01). A gateway whose `can_delete` is absent or true SHALL leave the action enabled. This mirrors the server authorization rather than replacing it; the API remains authoritative and SHALL still reject an unauthorized `DELETE` with `403`. + The `Connection` tab SHALL show this OpenShell CLI installation command in a code block: ```bash @@ -409,11 +411,11 @@ The gateway list SHALL: - Cancel in-flight search requests when new search input is received - Display total gateway count across all pages - Show pagination controls (page number, previous/next, items per page selector) -- Render delete actions on all gateway rows for platform:admin users +- Render the delete action on every visible gateway row, enabled only where the gateway's `can_delete` capability is true (the caller is a `gateway:owner` or `platform:admin`) and otherwise disabled with an accessible explanation - Require confirmation via a modal dialog before deleting any gateway - Display a platform:admin indicator (badge or status) when the user has this role -Platform administrators SHALL be able to delete any gateway from the list view. The delete action SHALL: +Platform administrators (and gateway owners, for gateways they own) SHALL be able to delete a gateway from the list view; the console SHALL gate the affordance on the gateway's `can_delete` capability as defined in WEB-UI-03. The delete action SHALL: - Trigger a confirmation modal with the gateway name - On confirmation, call `DELETE /api/hypershell/v1/gateways/{id}` @@ -421,7 +423,7 @@ Platform administrators SHALL be able to delete any gateway from the list view. - Invalidate the gateway collection query to refresh the list - Handle failures with clear error messaging and recovery guidance -**Verification:** Authenticate as a platform:admin user with 100+ gateways in the system. Verify the list displays 20 items by default, search filters results with 300ms debounce, pagination controls work correctly, total count is accurate, and delete actions are visible and functional. Verify rapid search input cancels previous requests. Authenticate as a non-admin user and verify delete actions are not visible and the list shows only owned/viewable gateways. +**Verification:** Authenticate as a platform:admin user with 100+ gateways in the system. Verify the list displays 20 items by default, search filters results with 300ms debounce, pagination controls work correctly, total count is accurate, and delete actions are enabled and functional. Verify rapid search input cancels previous requests. Authenticate as a non-admin user and verify the list shows only owned/viewable gateways, the delete action is enabled on gateways they own, and the delete action is disabled with an accessible explanation on gateways they can only view. #### Scenario: Platform admin sees all gateways with pagination @@ -454,14 +456,15 @@ Platform administrators SHALL be able to delete any gateway from the list view. - AND a success notification is displayed - AND the list is refreshed -#### Scenario: Non-admin user does not see delete actions +#### Scenario: Non-admin delete is gated by per-gateway ownership -- GIVEN user A has `gateway:owner` on gw-1 only +- GIVEN user A has `gateway:owner` on gw-1 and `gateway:viewer` on gw-2 - AND user A does NOT have `platform:admin` - WHEN user A views the gateway list -- THEN user A sees only gw-1 -- AND the delete action is not visible in the row actions menu -- AND user A cannot delete gateways they do not own +- THEN user A sees gw-1 and gw-2 +- AND the delete action for gw-1 (where `can_delete` is true) SHALL be enabled +- AND the delete action for gw-2 (where `can_delete` is false) SHALL be disabled with an accessible explanation +- AND the server SHALL reject a delete of gw-2 by user A with `403` regardless of the affordance ### Requirement WEB-UI-04: Gateway Placement Selection From aaa13a088d0ba1110888a8359c603dea6cf5a89e Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Wed, 7 Oct 2026 19:30:41 -0400 Subject: [PATCH 12/23] fix(gateways): render disabled delete action gray, not danger-red A caller who cannot delete a gateway got an aria-disabled Delete item that still carried isDanger. PatternFly's .pf-m-danger color rule is defined after .pf-m-aria-disabled in the stylesheet, so the red won over the disabled gray: the item was inactive (not clickable) but did not look it. Apply isDanger only when the caller can actually delete. Co-Authored-By: Claude Opus 4.8 --- .../src/gateways/gateway-detail-header.tsx | 2 +- .../gateway-management-ui/src/gateways/gateway-row-actions.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/gateway-management-ui/src/gateways/gateway-detail-header.tsx b/packages/gateway-management-ui/src/gateways/gateway-detail-header.tsx index 3367932db..4c21c9053 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-detail-header.tsx +++ b/packages/gateway-management-ui/src/gateways/gateway-detail-header.tsx @@ -201,7 +201,7 @@ function GatewayDetailActions({ { if (!canDelete) { return; diff --git a/packages/gateway-management-ui/src/gateways/gateway-row-actions.tsx b/packages/gateway-management-ui/src/gateways/gateway-row-actions.tsx index e364d6e22..468ef82f2 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-row-actions.tsx +++ b/packages/gateway-management-ui/src/gateways/gateway-row-actions.tsx @@ -107,7 +107,7 @@ export function GatewayRowActions({ { if (!canDelete) { return; From 1222afa5aad6b4d43fb63a6e171b05f18be3b6d9 Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Wed, 7 Oct 2026 19:55:06 -0400 Subject: [PATCH 13/23] feat(access): platform admins get owner-equivalent gateway access management A platform:admin can now perform all gateway access-management operations on any gateway - list, grant, change, and revoke any tier including owner, and search the directory - exactly like a gateway:owner, without holding a per-gateway binding. This is management-plane only: it creates no gateway-scoped binding for the admin, so the control-plane Role Bridge grants them no openshell login unless they are separately added to the gateway's access list. The change is one root-cause edit: caller tier resolution (callerTier) treats platform:admin as owner-equivalent, which flows through authorizeManage (incl. the owner-tier gate), SearchDirectory, and the console capabilities (can_manage_access/can_manage_owners). Last-owner protection (GAM-07) is target-based and still applies. The coarse REST gate already admitted platform:admin; no UI change is needed (controls gate on the capabilities). Specs updated: gateway-access-management GAM-01/04/08/09 + role vocabulary, rbac-enforcement platform:admin role/matrix/requirement, web-console access control gating, and the ephemeral-pr-environments platform:admin notes. Co-Authored-By: Claude Opus 4.8 --- .../plugins/gatewayAccess/integration_test.go | 82 +++++++++++++++++++ .../plugins/gatewayAccess/service.go | 53 +++++++++--- .../ephemeral-pr-environments.spec.md | 7 +- .../gateway-access-management.spec.md | 36 ++++++-- specs/security/rbac-enforcement.spec.md | 34 ++++++-- .../gateway-access-management.spec.md | 4 +- 6 files changed, 186 insertions(+), 30 deletions(-) diff --git a/components/api-server/plugins/gatewayAccess/integration_test.go b/components/api-server/plugins/gatewayAccess/integration_test.go index 28aeb1930..0ea549cbf 100644 --- a/components/api-server/plugins/gatewayAccess/integration_test.go +++ b/components/api-server/plugins/gatewayAccess/integration_test.go @@ -385,3 +385,85 @@ func userTierOf(svc gatewayAccess.Service, gw, userID string) (string, error) { } return "", nil } + +// seedPlatformAdmin provisions a user and gives them the global platform:admin +// binding (as JWT sync does in production), with NO gateway-scoped binding, so +// their access-management authority comes solely from platform:admin (GAM-08). +func seedPlatformAdmin(t *testing.T, username string) string { + t.Helper() + uid, err := userService().UpsertByUsername(context.Background(), strings.ToLower(username), nil, nil) + Expect(err).NotTo(HaveOccurred()) + Expect(rbService().SyncJWTRoles(context.Background(), uid, []string{roles.RolePlatformAdmin})).To(Succeed()) + return uid +} + +// hasGatewayBinding reports whether userID holds any gateway-scoped binding on gw. +func hasGatewayBinding(t *testing.T, userID, gw string) bool { + t.Helper() + summaries, err := rbService().FindBindingsByUserID(context.Background(), userID) + Expect(err).NotTo(HaveOccurred()) + for _, b := range summaries { + if b.Scope == roleBindings.ScopeGateway && b.GatewayID != nil && *b.GatewayID == gw { + return true + } + } + return false +} + +// GAM-08: a platform:admin manages access on any gateway with owner-equivalent +// authority (including assigning the owner tier) without holding a per-gateway +// binding, the reported capabilities reflect that, and doing so grants the +// platform admin no gateway login (no gateway-scoped binding is created for it). +func TestPlatformAdmin_ManagesAccessWithoutBindingOrLogin(t *testing.T) { + test.RegisterIntegration(t) + defer gatewayAccess.SetDirectoryResolver(nil) + gw := "gw-padmin-manage" + seed(t, gw, "owner-pa", roles.RoleGatewayOwner) + padmin := seedPlatformAdmin(t, "padmin-pa") + + dir := realm(gatewayAccess.DirectoryUser{Username: "erin", Name: "Erin"}) + svc := accessSvc(dir) + + // Capabilities are owner-equivalent even though padmin has no binding on gw. + _, _, caps, aerr := svc.List(context.Background(), gw, padmin, gatewayAccess.ListOptions{Page: 1, Size: 50}) + Expect(aerr).To(BeNil()) + Expect(caps.CallerRole).To(Equal(gatewayAccess.TierOwner)) + Expect(caps.CanManageAccess).To(BeTrue()) + Expect(caps.CanManageOwners).To(BeTrue()) + + // Can assign the owner tier - an owner-only operation a gateway:admin cannot do. + item, aerr := svc.Grant(context.Background(), gw, padmin, gatewayAccess.GrantInput{Username: "erin", Role: gatewayAccess.TierOwner}) + Expect(aerr).To(BeNil()) + Expect(item.Role).To(Equal(gatewayAccess.TierOwner)) + + // Can search the directory (GAM-09). + candidates, aerr := svc.SearchDirectory(context.Background(), gw, padmin, "er") + Expect(aerr).To(BeNil()) + Expect(len(candidates)).To(Equal(1)) + + // Login-safety: managing access created NO gateway-scoped binding for padmin, + // so the platform admin has no openshell login on gw. + Expect(hasGatewayBinding(t, padmin, gw)).To(BeFalse()) +} + +// GAM-07/GAM-08: a platform:admin may revoke an owner while another owner +// remains, but is still blocked from removing the last remaining owner. +func TestPlatformAdmin_RevokeOwnerGuardedByLastOwner(t *testing.T) { + test.RegisterIntegration(t) + defer gatewayAccess.SetDirectoryResolver(nil) + gw := "gw-padmin-revoke" + owner1 := seed(t, gw, "owner-r1", roles.RoleGatewayOwner) + owner2 := seed(t, gw, "owner-r2", roles.RoleGatewayOwner) + padmin := seedPlatformAdmin(t, "padmin-rv") + + svc := accessSvc(realm()) + + // Two owners exist: the platform admin may revoke one. + aerr := svc.Revoke(context.Background(), gw, padmin, owner2) + Expect(aerr).To(BeNil()) + + // One owner remains: the platform admin may not remove the last owner (GAM-07). + aerr = svc.Revoke(context.Background(), gw, padmin, owner1) + Expect(aerr).NotTo(BeNil()) + Expect(aerr.Status).To(Equal(http.StatusConflict)) +} diff --git a/components/api-server/plugins/gatewayAccess/service.go b/components/api-server/plugins/gatewayAccess/service.go index e1170b1cd..ef6604abd 100644 --- a/components/api-server/plugins/gatewayAccess/service.go +++ b/components/api-server/plugins/gatewayAccess/service.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/openshift-online/hypershell/components/api-server/pkg/rbac" "github.com/openshift-online/hypershell/components/api-server/plugins/roleBindings" "github.com/openshift-online/hypershell/components/api-server/plugins/roles" "github.com/openshift-online/hypershell/components/api-server/plugins/users" @@ -122,13 +123,10 @@ func NewService(d *dao, bindings roleBindings.RoleBindingService, userService us var _ Service = &service{} -// userTier returns the user's highest gateway tier on gatewayID (owner > admin -// > user), or "" if the user has no gateway-scoped binding there. -func (s *service) userTier(ctx context.Context, userID, gatewayID string) (string, *APIError) { - summaries, err := s.bindings.FindBindingsByUserID(ctx, userID) - if err != nil { - return "", serverError("unable to resolve access") - } +// highestGatewayTier returns the highest gateway-scoped tier (owner > admin > +// user) among summaries for gatewayID, or "" if none. It considers only explicit +// gateway-scoped bindings. +func highestGatewayTier(summaries []rbac.BindingSummary, gatewayID string) string { best := "" for _, b := range summaries { if b.Scope != roleBindings.ScopeGateway || b.GatewayID == nil || *b.GatewayID != gatewayID { @@ -139,7 +137,40 @@ func (s *service) userTier(ctx context.Context, userID, gatewayID string) (strin best = t } } - return best, nil + return best +} + +// userTier returns the user's highest gateway tier on gatewayID (owner > admin +// > user), or "" if the user has no gateway-scoped binding there. It reflects +// only explicit gateway-scoped bindings, so it is the correct reading of a +// target user's current tier. Authorization and capability decisions use +// callerTier instead, which also honors platform:admin. +func (s *service) userTier(ctx context.Context, userID, gatewayID string) (string, *APIError) { + summaries, err := s.bindings.FindBindingsByUserID(ctx, userID) + if err != nil { + return "", serverError("unable to resolve access") + } + return highestGatewayTier(summaries, gatewayID), nil +} + +// callerTier returns the caller's EFFECTIVE management tier on gatewayID. A +// platform:admin is owner-equivalent on every gateway (GAM-08) even without a +// per-gateway binding, so it resolves to owner; this governs manage +// authorization (authorizeManage), directory search, and the capabilities +// reported to the console (List). Resolving a platform admin as owner here never +// creates a binding for the caller, so it confers no gateway login +// (security/rbac-enforcement.spec.md, OpenShell Role Bridge). +func (s *service) callerTier(ctx context.Context, userID, gatewayID string) (string, *APIError) { + summaries, err := s.bindings.FindBindingsByUserID(ctx, userID) + if err != nil { + return "", serverError("unable to resolve access") + } + for _, b := range summaries { + if b.RoleName == roles.RolePlatformAdmin { + return TierOwner, nil + } + } + return highestGatewayTier(summaries, gatewayID), nil } func (s *service) List(ctx context.Context, gatewayID, callerUserID string, opts ListOptions) ([]GrantItem, int64, Capabilities, *APIError) { @@ -155,7 +186,7 @@ func (s *service) List(ctx context.Context, gatewayID, callerUserID string, opts for _, r := range rows { items = append(items, grantItemFromRow(r, creatorID)) } - tier, aerr := s.userTier(ctx, callerUserID, gatewayID) + tier, aerr := s.callerTier(ctx, callerUserID, gatewayID) if aerr != nil { return nil, 0, Capabilities{}, aerr } @@ -285,7 +316,7 @@ func (s *service) Revoke(ctx context.Context, gatewayID, callerUserID, targetUse } func (s *service) SearchDirectory(ctx context.Context, gatewayID, callerUserID, query string) ([]DirectoryCandidate, *APIError) { - tier, aerr := s.userTier(ctx, callerUserID, gatewayID) + tier, aerr := s.callerTier(ctx, callerUserID, gatewayID) if aerr != nil { return nil, aerr } @@ -312,7 +343,7 @@ func (s *service) SearchDirectory(ctx context.Context, gatewayID, callerUserID, // user who currently holds owner) requires the caller to be an owner. // newTier is "" for revoke; currentTier is "" for a fresh grant. func (s *service) authorizeManage(ctx context.Context, callerUserID, gatewayID, newTier, currentTier string) *APIError { - caller, aerr := s.userTier(ctx, callerUserID, gatewayID) + caller, aerr := s.callerTier(ctx, callerUserID, gatewayID) if aerr != nil { return aerr } diff --git a/specs/platform/ephemeral-pr-environments.spec.md b/specs/platform/ephemeral-pr-environments.spec.md index a8077e6e0..20df86778 100644 --- a/specs/platform/ephemeral-pr-environments.spec.md +++ b/specs/platform/ephemeral-pr-environments.spec.md @@ -1109,8 +1109,9 @@ Every GitHub identity that authenticates to a pull-request environment (whether by organization membership or by allowlist) SHALL be granted the realm roles needed to fully drive the environment: `platform:admin` and `gateway:creator`, as `../security/rbac-enforcement.spec.md` defines those roles. -`platform:admin` grants global gateway view and delete; it does not grant -gateway create. `gateway:creator` grants gateway create. The realm SHALL assign +`platform:admin` grants global gateway view, delete, and access management +(grant/change/revoke any tier, owner-equivalent, via the access facade, without +gateway login); it does not grant gateway create. `gateway:creator` grants gateway create. The realm SHALL assign both roles to brokered GitHub users on first broker login, so no manual role assignment is required after login. That is the same HyperShell API capability as the seeded `admin` test-tier principal (`ephemeral-test-credentials.spec.md` @@ -1485,7 +1486,7 @@ the shared harness and this workflow. | One updated comment per pull request, carrying the completed-swap commit SHA | The pull request shows the live environment's current state instead of a growing list of stale comments; pinning the SHA whose digest swap completed prevents claiming a commit the swap did not deploy | | GitHub brokering, not Red Hat SSO | These are developer/debug environments; GitHub identity plus an organization gate and allowlist lets an outside contributor log in to an origin-repo environment, where Red Hat SSO would tie the environment to production identity | | Organization gate by default, allowlist for extras | Organization membership is the common case; the additive allowlist admits outside contributors to login without adding them to the organization. Enforcing both at BFF login is sufficient: the console API bearer only exists after a HyperShell session is created, so a denied user never receives one. A custom Keycloak image is not required | -| Authenticated users get `platform:admin` and `gateway:creator`; narrower tiers by self-service impersonation | `platform:admin` is view and delete only; create requires `gateway:creator`. A single GitHub identity federates to one Keycloak user, so there is no account picker between tiers. Reusing the shared `developer` and `platform-admin` test-tier principals (`ephemeral-test-credentials.spec.md`) as impersonation targets, open to any authenticated user rather than only a designated admin, lets every contributor verify a narrower HyperShell API boundary with the same login and without a password. The human path is the hypershell-realm Keycloak admin console (`/admin/hypershell/console/`), GitHub or a seeded username/password, then Impersonate on those two principals; master-realm `/admin/` is not that path. The `impersonation` role that mapper grants is realm-wide (Keycloak has no target-scoped form of it without user-level FGAP, which this workflow does not configure). Token-exchange impersonation stays on `hypershell-e2e` and does not include other GitHub-brokered users. Per-gateway sandbox create stays an e2e driver grant. Impersonation works for the environment lifetime; e2e does not de-seed | +| Authenticated users get `platform:admin` and `gateway:creator`; narrower tiers by self-service impersonation | `platform:admin` is view, delete, and access management (not create; create requires `gateway:creator`). A single GitHub identity federates to one Keycloak user, so there is no account picker between tiers. Reusing the shared `developer` and `platform-admin` test-tier principals (`ephemeral-test-credentials.spec.md`) as impersonation targets, open to any authenticated user rather than only a designated admin, lets every contributor verify a narrower HyperShell API boundary with the same login and without a password. The human path is the hypershell-realm Keycloak admin console (`/admin/hypershell/console/`), GitHub or a seeded username/password, then Impersonate on those two principals; master-realm `/admin/` is not that path. The `impersonation` role that mapper grants is realm-wide (Keycloak has no target-scoped form of it without user-level FGAP, which this workflow does not configure). Token-exchange impersonation stays on `hypershell-e2e` and does not include other GitHub-brokered users. Per-gateway sandbox create stays an e2e driver grant. Impersonation works for the environment lifetime; e2e does not de-seed | | Area 9 grants `openshell-user` on the gateway client before token exchange | Kind already uses `assign_gateway_client_role` because there is no user-id discovery path to create a `gateway:viewer` RoleBinding for a non-owner. The OpenShift driver does the same so a brokered PR env has a specified path to an `openshell-user` token. That grant is not a Keycloak realm-role seed | | Dedicated `hypershell-e2e` client, imported only when enabled | Brokered GitHub users have no password grant. A per-PR realm cannot share a standing AWS or Actions provisioner secret, and `hypershell-provisioner` is too privileged (`manage-clients` / `manage-users`). Token exchange onto the HyperShell API client and onto the per-gateway client covers area 9 without a password grant. Omitting the client from Kind/local/hub imports (and gating control-plane grants on `enabled==true`) keeps the impersonation identity out of production reconcile paths. `E2E_OIDC_GRANT` keeps Kind and local OpenShift on the password grant against the static seeds | | Standing CI secrets in AWS Secrets Manager, not GitHub Actions | Cluster login and the GitHub OAuth App must rotate with the fleet and, for OAuth, must also land in Keycloak. `ephemeral-ci-secrets.spec.md` owns that inventory, the origin-only job-level OIDC gate, GitHub OIDC fetch of cluster login, and ESO alignment of OAuth so this spec can treat them as cluster infrastructure | diff --git a/specs/platform/gateway-access-management.spec.md b/specs/platform/gateway-access-management.spec.md index e9576dafc..96dcf95e7 100644 --- a/specs/platform/gateway-access-management.spec.md +++ b/specs/platform/gateway-access-management.spec.md @@ -33,6 +33,8 @@ The three per-gateway roles form a strict hierarchy, **Owner > Admin > Viewer**: `gateway:owner` and `gateway:admin` are indistinguishable **to the gateway**: both are gateway administrators and receive the same Keycloak client roles. Their difference is entirely a management-plane concern -- only owners may delete the gateway and assign other owners. A gateway always retains at least one owner (GAM-07). +Separately, `platform:admin` (a global, Keycloak-sourced role, not a per-gateway role) has **owner-equivalent access-management authority on every gateway**: it may list, grant, change, and revoke any tier (including `gateway:owner`) and search the directory, without holding any per-gateway binding (GAM-08). It is purely a management-plane authority: it confers **no gateway login** (no Keycloak client roles) unless the `platform:admin` is separately granted a per-gateway binding, so a platform administrator managing a gateway's access does not thereby gain `openshell` CLI access to it. + The creator (the auto-provisioned first owner, `security/rbac-enforcement.spec.md`) is tracked for display and audit (`is_creator`, "Created by") but holds no capability beyond any other owner; ownership is shared and transferable among owners. ## Non-Goals @@ -53,7 +55,7 @@ The platform SHALL define a per-gateway built-in role `gateway:admin` representi - Manage access **for the Admin and User tiers only**: grant, change, and revoke `gateway:admin` and `gateway:viewer` bindings (GAM-08). - Create and manage all OpenShellGatewayServiceAccounts on the gateway, selecting `openshell-user` or `openshell-admin` (GAM-12). -`gateway:admin` SHALL NOT delete the gateway, and SHALL NOT grant, change, or revoke the Owner tier (`gateway:owner`). Deleting a gateway SHALL require `gateway:owner` or `platform:admin`; assigning an owner SHALL require `gateway:owner` (GAM-08). +`gateway:admin` SHALL NOT delete the gateway, and SHALL NOT grant, change, or revoke the Owner tier (`gateway:owner`). Deleting a gateway SHALL require `gateway:owner` or `platform:admin`; assigning an owner SHALL require `gateway:owner` or `platform:admin` (GAM-08). The Gateway REST resource SHALL advertise a read-only, per-caller `can_delete` boolean on `GET` (list and single) responses, computed from the authenticated caller's current bindings with the **same check that authorizes `DELETE`** (`gateway:owner` on that gateway, or `platform:admin`). `can_delete` is advisory: it exists so clients (console, CLI) can disable a delete affordance the API would reject, and SHALL NOT weaken enforcement. The API server SHALL remain the authorization boundary and SHALL enforce delete authorization on `DELETE` regardless of `can_delete`. When RBAC enforcement is disabled, every delete is permitted, so `can_delete` SHALL be `true`. @@ -167,7 +169,7 @@ Any caller with a binding on the gateway (owner, admin, or viewer) or `platform: The API server SHALL expose `POST /api/hypershell/v1/gateways/{gateway_id}/access` to grant a user `owner`, `admin`, or `user` access to the gateway. -The request SHALL identify the target user by a directory identity (`username`, or an equivalent Keycloak subject reference returned by GAM-09) and a `role` of `owner`, `admin`, or `user`. Granting `owner` SHALL create a `gateway:owner` binding; granting `admin` SHALL create a `gateway:admin` binding; granting `user` SHALL create a `gateway:viewer` binding. Granting (or changing to) the `owner` tier SHALL require the caller to be a `gateway:owner` (GAM-08); `gateway:admin` callers SHALL NOT assign owners. +The request SHALL identify the target user by a directory identity (`username`, or an equivalent Keycloak subject reference returned by GAM-09) and a `role` of `owner`, `admin`, or `user`. Granting `owner` SHALL create a `gateway:owner` binding; granting `admin` SHALL create a `gateway:admin` binding; granting `user` SHALL create a `gateway:viewer` binding. Granting (or changing to) the `owner` tier SHALL require the caller to be a `gateway:owner` or `platform:admin` (GAM-08); `gateway:admin` callers SHALL NOT assign owners. The target identity SHALL be resolved against the Keycloak realm directory before any binding is created. If the identity does not correspond to a user in the realm, the request SHALL be rejected with `404 Not Found` and a clear error (the person must exist in the realm to be granted access); no `User` record SHALL be pre-provisioned and no binding SHALL be created. Resolution SHALL reuse the directory projection / lookup of GAM-09. @@ -321,9 +323,13 @@ The creator (the auto-provisioned first owner) is retained as `is_creator` for d ### Requirement: GAM-08 -- Access Management Authorization -Granting, changing, and revoking access (GAM-04, GAM-05, GAM-06) and searching the directory (GAM-09) SHALL require `gateway:owner` or `gateway:admin` on the target gateway. Operations that touch the **Owner tier** -- granting `owner`, promoting to `owner`, or demoting/revoking a user who holds `gateway:owner` -- SHALL additionally require the caller to be a `gateway:owner`. A `gateway:admin` caller MAY manage only the Admin and User tiers. +Granting, changing, and revoking access (GAM-04, GAM-05, GAM-06) and searching the directory (GAM-09) SHALL require `gateway:owner` or `gateway:admin` on the target gateway, or `platform:admin`. Operations that touch the **Owner tier** -- granting `owner`, promoting to `owner`, or demoting/revoking a user who holds `gateway:owner` -- SHALL additionally require the caller to be a `gateway:owner` or `platform:admin`. A `gateway:admin` caller MAY manage only the Admin and User tiers. + +`gateway:viewer` callers SHALL NOT manage access or search the directory. + +A `platform:admin` SHALL be authorized for **all** access-management operations on **any** gateway -- listing, granting, changing, and revoking **any** tier including `gateway:owner`, and searching the directory -- exactly like a `gateway:owner`, and **without requiring any per-gateway binding**. This authority is management-plane only: performing access management SHALL NOT grant the `platform:admin` gateway login (the per-gateway Keycloak client roles `openshell-admin`/`openshell-user`); gateway login follows solely from an explicit gateway-scoped binding on that gateway (`security/rbac-enforcement.spec.md`, OpenShell Role Bridge). Last-owner protection (GAM-07) applies to `platform:admin` actions exactly as to an owner's: a `platform:admin` SHALL NOT remove or demote the last remaining owner, but MAY assign a new owner first. The access-list capabilities (GAM-03) returned to a `platform:admin` SHALL report `can_manage_access` and `can_manage_owners` as true. -`gateway:viewer` callers SHALL NOT manage access or search the directory. `platform:admin` SHALL NOT grant, change, or revoke access unless it also holds `gateway:owner` or `gateway:admin` on that gateway (consistent with `platform:admin` being view/delete-only for RBAC grants in `security/rbac-enforcement.spec.md`). Unauthorized management attempts SHALL return `403`; callers with no access to the gateway SHALL receive `404`. +Unauthorized management attempts SHALL return `403`; callers with no access to the gateway SHALL receive `404`. #### Scenario: Viewer cannot grant access @@ -337,6 +343,26 @@ Granting, changing, and revoking access (GAM-04, GAM-05, GAM-06) and searching t - WHEN user B grants user D the `user` role on gw-1 - THEN the grant SHALL be created +#### Scenario: Platform admin manages access on any gateway without a binding + +- GIVEN user P holds `platform:admin` and has no binding on gw-1 +- WHEN user P grants user E the `owner` role on gw-1 +- THEN the grant SHALL be created +- AND user P MAY likewise change and revoke any tier on gw-1, including owners (subject to GAM-07), and search the directory + +#### Scenario: Platform admin managing access gains no gateway login + +- GIVEN user P holds `platform:admin` and has no gateway-scoped binding on gw-1 +- WHEN user P grants, changes, or revokes access on gw-1 +- THEN no gateway-scoped binding SHALL be created for user P +- AND the Role Bridge SHALL assign user P no `openshell-admin` or `openshell-user` client role on gw-1 (user P has no gateway login unless separately granted a binding) + +#### Scenario: Platform admin cannot remove the last owner + +- GIVEN gw-1 has exactly one owner, user A, and user P holds `platform:admin` +- WHEN user P revokes or demotes user A +- THEN the response SHALL be `409 Conflict` (a gateway must keep at least one owner, GAM-07) + --- ### Requirement: GAM-09 -- Keycloak Directory Search @@ -347,7 +373,7 @@ Each candidate SHALL expose `username`, `name`, and `email`, and SHALL be usable **Backing store.** Rather than issuing a live Keycloak Admin REST query on every keystroke, the directory SHALL be served from a **control-plane-maintained projection of realm users** that the control plane refreshes periodically (and MAY refresh on demand). The control plane -- which holds the `hypershell-keycloak-admin` Secret and already runs reconcile loops -- SHALL periodically list realm users via the Keycloak Admin REST API and persist a directory projection (`username`, `name`, `email`, Keycloak subject) that the API server reads and filters for this endpoint. The API server SHALL NOT read the `hypershell-keycloak-admin` Secret (`openshell-gateway-keycloak.spec.md`). This keeps search fast and resilient to transient Keycloak unavailability; the tradeoff is bounded staleness equal to the refresh interval. Because the projection may be stale, grant (GAM-04) SHALL re-validate the chosen identity against the realm at grant time and reject unknown users (`404`). The refresh interval SHALL be configuration, not code (`specs/standards/`), and newly added realm users become selectable within one refresh cycle. Results SHALL be bounded (paginated/capped); the search SHALL apply the query term server-side against the projection. -Authorization SHALL follow GAM-08 (owner or admin on the gateway). +Authorization SHALL follow GAM-08 (owner or admin on the gateway, or `platform:admin`). #### Scenario: Directory search returns realm users from the projection diff --git a/specs/security/rbac-enforcement.spec.md b/specs/security/rbac-enforcement.spec.md index 66c1c9dee..750137a3a 100644 --- a/specs/security/rbac-enforcement.spec.md +++ b/specs/security/rbac-enforcement.spec.md @@ -106,7 +106,7 @@ Role ||--o{ RoleBinding : "granted_by" | Role | Scope | Source | Purpose | |------|-------|--------|---------| -| `platform:admin` | global | Keycloak JWT | Platform-wide administration; can view and delete any gateway | +| `platform:admin` | global | Keycloak JWT | Platform-wide administration; can view and delete any gateway, and manage any gateway's access (grant/change/revoke any tier, including owner) via the access facade -- without gaining gateway login | | `gateway:creator` | global | Keycloak JWT | Can create gateways; auto-becomes `gateway:owner` on creation | | `gateway:owner` | per gateway | DB (app logic) | Gateway owner; one or more; grantable and removable (but a gateway always keeps at least one owner). Full CRUD including delete; can grant/change/revoke any role including `gateway:owner`. The creator is the first owner | | `gateway:admin` | per gateway | DB (app logic) | Granted administrator; zero or more; removable. Read/update the gateway (no delete); can grant/change/revoke `gateway:admin` and `gateway:viewer` only (not `gateway:owner`) | @@ -117,7 +117,7 @@ Role ||--o{ RoleBinding : "granted_by" | Role | Gateways | Gateway CRUD | RBAC Grants | OpenShell Mapping | OpenShellGatewayServiceAccounts | ManagedCluster Registration | |------|----------|-------------|-------------|-------------------|-----------------|-----------------| -| `platform:admin` | view all, delete any | view all + delete any | -- | -- | None without a gateway binding | View all records; create, update, or delete any record | +| `platform:admin` | view all, delete any | view all + delete any | grant/change/revoke owner/admin/viewer on any gateway (via the access facade, GAM-04/05/06) | None without a gateway binding | None without a gateway binding | View all records; create, update, or delete any record | | `gateway:creator` | create + own gateways | full (as owner) | grant owner/admin/viewer on own gateways | `openshell-admin` + `openshell-user` on own gateways | Through the resulting owner binding | View all records; no writes | | `gateway:owner` | full (one gateway) | full incl. delete | grant owner/admin/viewer on that gateway | `openshell-admin` + `openshell-user` on that gateway | Select `openshell-user` or `openshell-admin`. Manage all OpenShellGatewayServiceAccounts on the gateway. | -- | | `gateway:admin` | full except delete (one gateway) | read + update (no delete) | grant admin/viewer on that gateway | `openshell-admin` + `openshell-user` on that gateway | Select `openshell-user` or `openshell-admin`. Manage all OpenShellGatewayServiceAccounts on the gateway. | -- | @@ -325,29 +325,36 @@ the same RoleBinding records. ### Requirement: Platform Admin Global Access -Users with the `platform:admin` role SHALL have global view and delete permissions across -all gateways in the platform, regardless of per-gateway RoleBindings. This role is assigned -via Keycloak and synced to the database identically to `gateway:creator`. +Users with the `platform:admin` role SHALL have global view, delete, and access-management +permissions across all gateways in the platform, regardless of per-gateway RoleBindings. +Access management (grant/change/revoke any tier, including owner, and directory search) +is owner-equivalent and exercised through the access facade; it creates no gateway-scoped +binding for the platform admin, so it confers no gateway login +(`platform/gateway-access-management.spec.md` GAM-08). This role is assigned via Keycloak +and synced to the database identically to `gateway:creator`. Platform administrators SHALL be able to: - List all gateways across the platform (GET `/api/hypershell/v1/gateways` returns all) - View any specific gateway (GET `/api/hypershell/v1/gateways/{id}` succeeds for any ID) - Delete any gateway (DELETE `/api/hypershell/v1/gateways/{id}` succeeds for any ID) +- Manage any gateway's access through the access facade (`/api/hypershell/v1/gateways/{id}/access`): list, grant, change, and revoke any tier including `gateway:owner`, and search the directory -- owner-equivalent, without holding a per-gateway binding, and subject to last-owner protection (`platform/gateway-access-management.spec.md` GAM-07/GAM-08) Platform administrators SHALL NOT be able to: - Modify gateway configuration (PATCH/PUT operations require `gateway:owner` or `gateway:admin`) -- Grant or revoke RoleBindings (requires `gateway:owner` or `gateway:admin` on that specific gateway) +- Create or delete `RoleBinding` records through the low-level generic `/role_bindings` resource (that endpoint still requires `gateway:owner` or `gateway:admin` on the gateway; platform administrators manage access through the access facade instead) - Create gateways (requires `gateway:creator` role) +- Obtain gateway login from managing access: performing access management creates no gateway-scoped binding for the platform administrator, so they receive no `openshell` CLI access unless separately granted a per-gateway binding (see OpenShell Role Bridge above) The `platform:admin` role is orthogonal to `gateway:creator`, `gateway:owner`, and `gateway:viewer`. A user may hold multiple roles (e.g., `platform:admin` + `gateway:creator`). -In the initial implementation, the `platform:admin` role is **limited to gateway view and delete operations**. It does NOT grant permissions to: +The `platform:admin` role covers gateway **view**, **delete**, and **access management** (the access facade; `platform/gateway-access-management.spec.md`). It does NOT grant permissions to: +- Modify gateway configuration (PATCH), or create gateways (requires `gateway:creator`) - View or modify GatewayNetworks, GatewayReleases, or ManagedClusters -- View or modify Users or RoleBindings +- Create or delete `RoleBinding` records through the generic `/role_bindings` resource (access is managed through the access facade instead) - Access platform-level configuration or system administration functions Future iterations may expand platform:admin permissions to include these resources. @@ -377,6 +384,14 @@ Future iterations may expand platform:admin permissions to include these resourc - THEN the gateway is deleted - AND the response is 204 No Content +#### Scenario: Platform admin manages gateway access without a binding + +- GIVEN user A has `platform:admin` +- AND user A has no binding for gw-1 (owned by user B) +- WHEN user A grants user C the `owner` role on gw-1 via `POST /api/hypershell/v1/gateways/gw-1/access` +- THEN the grant SHALL succeed +- AND no gateway-scoped binding SHALL be created for user A (user A gains no `openshell` login on gw-1) + #### Scenario: Platform admin cannot modify gateways without ownership - GIVEN user A has `platform:admin` only @@ -391,12 +406,13 @@ Future iterations may expand platform:admin permissions to include these resourc - WHEN user A calls `POST /api/hypershell/v1/gateways` - THEN the response is 403 Forbidden -#### Scenario: Platform admin cannot grant role bindings +#### Scenario: Platform admin cannot use the low-level role_bindings resource - GIVEN user A has `platform:admin` only - AND user A has no `gateway:owner` binding for gw-1 - WHEN user A calls `POST /api/hypershell/v1/role_bindings` with `gateway_id=gw-1` - THEN the response is 403 Forbidden +- (platform administrators manage access through the access facade `/gateways/{id}/access`, not this generic resource) #### Scenario: User with platform:admin and gateway:creator can create and view all diff --git a/specs/web-console/gateway-access-management.spec.md b/specs/web-console/gateway-access-management.spec.md index eb348affe..805b2f0c4 100644 --- a/specs/web-console/gateway-access-management.spec.md +++ b/specs/web-console/gateway-access-management.spec.md @@ -135,9 +135,9 @@ The confirmation SHALL name the user being removed. Removing one's own access SH ### Requirement: GAM-UI-07 -- Management Controls Gated to Administrators -Access management controls (Add users, the inline role control, and Remove access) SHALL be presented only to callers the server authorizes to manage access (gateway owner or admin, GAM-08). Viewers SHALL see the access list read-only, without those controls. +Access management controls (Add users, the inline role control, and Remove access) SHALL be presented only to callers the server authorizes to manage access -- those for whom the access-list capabilities report `can_manage_access` (a gateway owner or admin, or a `platform:admin`; GAM-08). Viewers SHALL see the access list read-only, without those controls. The console SHALL drive these gates from the server-reported capabilities (`can_manage_access`, `can_manage_owners`), not from the caller's own listed role, so a `platform:admin` who is not on the access list still receives the full controls. -Controls that touch the **Owner tier** -- assigning the Owner role, or changing/removing a user who is an owner -- SHALL be offered only to owner callers; for non-owner admins these SHALL be absent or disabled with an accessible explanation. +Controls that touch the **Owner tier** -- assigning the Owner role, or changing/removing a user who is an owner -- SHALL be offered only when the capabilities report `can_manage_owners` (a gateway owner, or a `platform:admin`); for non-owner admins these SHALL be absent or disabled with an accessible explanation. The console SHALL rely on server authorization as the source of truth: it SHALL handle `403` and `409` responses with localized messaging and SHALL NOT present a management outcome the server rejected as if it succeeded. From 57d60082a2f0d34bec381e20eafe102e1e392df3 Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Wed, 7 Oct 2026 20:10:05 -0400 Subject: [PATCH 14/23] test(e2e): platform admin manages access on a gateway it does not own Add a scenario to area 9b (gateway access management) that verifies the platform:admin owner-equivalent access-management feature end to end: 1. alice (a plain creator, not a platform admin) creates her own gateway and is its sole owner. 2. the admin user (platform:admin) holds no binding on that gateway - it is absent from the access list, which shows only alice as owner. 3. the admin user adds bob as a user through the access facade anyway, exactly like an owner, and bob appears in the list as 'user'. Reuses the area 9b access-facade helpers and guards (long mode, kind driver, password grant); cleans up alice's gateway via the platform-admin delete path. Co-Authored-By: Claude Opus 4.8 --- tests/e2e/e2e-openshell.sh | 78 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/tests/e2e/e2e-openshell.sh b/tests/e2e/e2e-openshell.sh index 1cb91ce85..659f1c996 100755 --- a/tests/e2e/e2e-openshell.sh +++ b/tests/e2e/e2e-openshell.sh @@ -2308,6 +2308,84 @@ print(json.dumps({'name':os.environ['GW_NAME'],'cluster_id':os.environ['E2E_CLUS fi fi + # == Scenario 4: a platform:admin manages access on a gateway it does NOT own == + # (gateway-access-management.spec.md GAM-08). A plain creator (${E2E_GAM_USER}) + # creates her own gateway and is its sole owner; the admin user (platform:admin, + # E2E_OIDC_USERNAME) holds no binding on it, yet may add a user through the access + # facade exactly like an owner, without being on the access list. + acquire_oidc_token "$E2E_GAM_USER" "$E2E_GAM_USER" 2>/dev/null || true + GAM_ALICE_API_TOKEN="${_OIDC_ACCESS_TOKEN}" + acquire_oidc_token 2>/dev/null || true # restore admin token (platform:admin) + if [[ -z "$GAM_ALICE_API_TOKEN" ]]; then + fail_test "Access management: could not acquire ${E2E_GAM_USER} API token to create her gateway" + else + # 1. ${E2E_GAM_USER} creates a gateway (kind grants gateway:creator by default) + # and becomes its first and only gateway:owner. + GAM_PA_GW_NAME="e2e-padmin-gw-$(date +%s | tail -c5)" + GAM_PA_GW_BODY=$(GW_NAME="$GAM_PA_GW_NAME" E2E_OIDC_ISSUER="$E2E_OIDC_ISSUER" \ + E2E_OIDC_CLIENT_ID="$E2E_OIDC_CLIENT_ID" E2E_CLUSTER_ID="${E2E_CLUSTER_ID:-}" python3 -c " +import json, os +print(json.dumps({'name':os.environ['GW_NAME'],'cluster_id':os.environ['E2E_CLUSTER_ID'], + 'oidc':json.dumps({'issuer':os.environ['E2E_OIDC_ISSUER'],'audience':os.environ['E2E_OIDC_CLIENT_ID'], + 'roles_claim':'groups','admin_role':'hypershell-admins','user_role':'hypershell-users'}), + 'route':json.dumps({'enabled':True})}))") + show_cmd "POST ${API_HOST}/api/hypershell/v1/gateways (as ${E2E_GAM_USER}) -> expect 201" + GAM_PA_RESP_F=$(mktemp) + GAM_PA_GW_STATUS=$(_driver_curl -o "$GAM_PA_RESP_F" -w '%{http_code}' -X POST \ + "${API_HOST}/api/hypershell/v1/gateways" \ + -H "Authorization: Bearer ${GAM_ALICE_API_TOKEN}" -H "Content-Type: application/json" \ + -d "$GAM_PA_GW_BODY" 2>/dev/null || true) + GAM_PA_GW_ID=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('id',''))" "$GAM_PA_RESP_F" 2>/dev/null || true) + rm -f "$GAM_PA_RESP_F" + if [[ "$GAM_PA_GW_STATUS" =~ ^2 && -n "$GAM_PA_GW_ID" ]]; then + E2E_EXTRA_GW_IDS+=("$GAM_PA_GW_ID") # fail-safe cleanup + pass "${E2E_GAM_USER} created her own gateway ${GAM_PA_GW_NAME} (HTTP ${GAM_PA_GW_STATUS})" + else + fail_test "Access management: ${E2E_GAM_USER} gateway create returned HTTP ${GAM_PA_GW_STATUS:-none}" + GAM_PA_GW_ID="" + fi + + if [[ -n "$GAM_PA_GW_ID" ]]; then + # 2. the admin user (platform:admin) is NOT on ${E2E_GAM_USER}'s access list. It + # may read the list (platform:admin, GAM-03); ${E2E_GAM_USER} is the sole owner. + GAM_PA_ADMIN_ROLE=$(gam_user_role "$GAM_PA_GW_ID" "$GAM_OWNER_API_TOKEN" "$E2E_OIDC_USERNAME") + GAM_PA_ALICE_ROLE=$(gam_user_role "$GAM_PA_GW_ID" "$GAM_OWNER_API_TOKEN" "$E2E_GAM_USER") + if [[ -z "$GAM_PA_ADMIN_ROLE" && "$GAM_PA_ALICE_ROLE" == "owner" ]]; then + pass "Platform admin (${E2E_OIDC_USERNAME}) is not listed on ${E2E_GAM_USER}'s gateway (owner=${E2E_GAM_USER})" + else + fail_test "Access management: expected ${E2E_OIDC_USERNAME} absent and ${E2E_GAM_USER}=owner, got admin='${GAM_PA_ADMIN_ROLE}' ${E2E_GAM_USER}='${GAM_PA_ALICE_ROLE}'" + fi + + # 3. the admin user (platform:admin, no binding here) adds ${E2E_GAM_ADMIN} as a + # user -- the owner-equivalent access management of GAM-08. + gam_wait_directory "$GAM_PA_GW_ID" "$GAM_OWNER_API_TOKEN" "$E2E_GAM_ADMIN" || true + show_cmd "POST .../gateways/${GAM_PA_GW_ID}/access {user: ${E2E_GAM_ADMIN}, role: user} (as platform admin ${E2E_OIDC_USERNAME})" + GAM_CODE=$(gam_grant "$GAM_PA_GW_ID" "$GAM_OWNER_API_TOKEN" "$E2E_GAM_ADMIN" user) + if [[ "$GAM_CODE" =~ ^2 ]]; then + pass "Platform admin (${E2E_OIDC_USERNAME}) added ${E2E_GAM_ADMIN} as a user on a gateway it does not own (HTTP ${GAM_CODE})" + else + fail_test "Access management: platform-admin grant of ${E2E_GAM_ADMIN} returned HTTP ${GAM_CODE:-none} (want 2xx)" + dim " ${GAM_LAST_BODY:0:200}" + fi + if [[ "$(gam_user_role "$GAM_PA_GW_ID" "$GAM_OWNER_API_TOKEN" "$E2E_GAM_ADMIN")" == "user" ]]; then + pass "${E2E_GAM_ADMIN} now holds the 'user' tier on ${E2E_GAM_USER}'s gateway" + else + fail_test "Access management: ${E2E_GAM_ADMIN} is not listed as 'user' after the platform-admin grant" + fi + + # cleanup: delete ${E2E_GAM_USER}'s gateway as the platform admin (GAM-01). + GAM_PA_DEL_F=$(mktemp) + GAM_PA_DEL_CODE=$(_driver_curl -o "$GAM_PA_DEL_F" -w '%{http_code}' -X DELETE \ + "${API_HOST}/api/hypershell/v1/gateways/${GAM_PA_GW_ID}" \ + -H "Authorization: Bearer ${GAM_OWNER_API_TOKEN}" 2>/dev/null || true) + rm -f "$GAM_PA_DEL_F" + if [[ "$GAM_PA_DEL_CODE" == "204" || "$GAM_PA_DEL_CODE" == "200" ]]; then + GAM_NEW_EXTRA=(); for _x in "${E2E_EXTRA_GW_IDS[@]}"; do [[ "$_x" != "$GAM_PA_GW_ID" ]] && GAM_NEW_EXTRA+=("$_x"); done + E2E_EXTRA_GW_IDS=("${GAM_NEW_EXTRA[@]}") + fi + fi + fi + acquire_oidc_token 2>/dev/null || true # restore admin token for later areas fi sep From 5e87aecd18714d06a3d4dccafd5ad3b95fb764dd Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Wed, 7 Oct 2026 20:19:47 -0400 Subject: [PATCH 15/23] feat(access): tooltip on disabled last-owner role and remove controls The last remaining owner (and owner rows a non-owner caller cannot manage) already disabled the row's role control and Remove access action, but the reason was only an HTML title attribute. Surface it as a PatternFly Tooltip on hover/focus, matching the disabled "Delete gateway" affordance. Both controls now render isAriaDisabled (grayed but focusable, so the reason is announced and the tooltip fires) wrapped in a Tooltip carrying the reason. The role control, which cannot be changed in this state, renders as a disabled plain button showing the current role instead of an interactive Select. Co-Authored-By: Claude Opus 4.8 --- .../src/access/access-page.test.tsx | 4 +- .../src/access/access-row-actions.tsx | 58 ++++++++++++++----- .../gateway-access-management.spec.md | 2 +- 3 files changed, 45 insertions(+), 19 deletions(-) diff --git a/packages/gateway-management-ui/src/access/access-page.test.tsx b/packages/gateway-management-ui/src/access/access-page.test.tsx index 970b95316..755b4f22e 100644 --- a/packages/gateway-management-ui/src/access/access-page.test.tsx +++ b/packages/gateway-management-ui/src/access/access-page.test.tsx @@ -160,7 +160,7 @@ describe("AccessPage", () => { }); expect(controls.length).toBeGreaterThanOrEqual(1); controls.forEach((control) => { - expect(control.disabled).toBe(true); + expect(control.getAttribute("aria-disabled")).toBe("true"); }); }); @@ -176,7 +176,7 @@ describe("AccessPage", () => { }); expect(ownerControls.length).toBeGreaterThanOrEqual(1); ownerControls.forEach((control) => { - expect(control.disabled).toBe(true); + expect(control.getAttribute("aria-disabled")).toBe("true"); }); // The user row remains actionable. expect( diff --git a/packages/gateway-management-ui/src/access/access-row-actions.tsx b/packages/gateway-management-ui/src/access/access-row-actions.tsx index 7f3d0975e..fc89a19b2 100644 --- a/packages/gateway-management-ui/src/access/access-row-actions.tsx +++ b/packages/gateway-management-ui/src/access/access-row-actions.tsx @@ -11,6 +11,7 @@ import { SelectOption, MenuToggle, type MenuToggleElement, + Tooltip, } from "@patternfly/react-core"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useId, useState } from "react"; @@ -63,20 +64,32 @@ export function AccessRoleControl({ retry: false, }); - const disabled = disabledReason !== null || mutation.isPending; const label = roleLabel(intl.formatMessage, grant.role); const controlLabel = `${intl.formatMessage(messages.accessChangeRoleLabel)}: ${userDisplayName(grant)}`; + // When the row cannot be managed (e.g. the last remaining owner, GAM-UI-08, or + // an owner row for a non-owner caller), present the current role as a disabled + // control with a tooltip explaining why, mirroring the disabled "Delete gateway" + // affordance. isAriaDisabled keeps it focusable so the reason is announced. + if (disabledReason !== null) { + return ( + + + + ); + } + const toggle = (toggleRef: React.Ref) => ( { setIsOpen((open) => !open); }} ref={toggleRef} - title={disabledReason ?? undefined} variant="plainText" > {label} @@ -145,20 +158,33 @@ export function AccessRemoveAction({ disabledReason ?? `${intl.formatMessage(messages.accessRemove)}: ${userDisplayName(grant)}`; + // The last remaining owner (and owner rows for non-owner callers) cannot be + // removed (GAM-UI-08). Disable the action and explain why with a tooltip, as the + // disabled "Delete gateway" affordance does. isAriaDisabled keeps it focusable. + const removeButton = ( + + ); + return ( <> - + {disabledReason !== null ? ( + {removeButton} + ) : ( + removeButton + )} {isOpen ? ( Date: Wed, 7 Oct 2026 20:27:33 -0400 Subject: [PATCH 16/23] feat(access): rename access table "User name" column to "Name" Co-Authored-By: Claude Opus 4.8 --- components/web-console/locales/en.json | 2 +- packages/gateway-management-ui/src/messages.ts | 2 +- specs/platform/gateway-access-management.spec.md | 2 +- specs/web-console/gateway-access-management.spec.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/web-console/locales/en.json b/components/web-console/locales/en.json index b5184f0db..c0c5de917 100644 --- a/components/web-console/locales/en.json +++ b/components/web-console/locales/en.json @@ -760,7 +760,7 @@ "description": "Access table column for the username." }, "app.gateway.access.column.userName": { - "defaultMessage": "User name", + "defaultMessage": "Name", "description": "Access table column for the user's display name." }, "app.gateway.access.creatorMarker": { diff --git a/packages/gateway-management-ui/src/messages.ts b/packages/gateway-management-ui/src/messages.ts index 11e0a42b1..729d8861b 100644 --- a/packages/gateway-management-ui/src/messages.ts +++ b/packages/gateway-management-ui/src/messages.ts @@ -1101,7 +1101,7 @@ export const messages = defineMessages({ }, accessUserName: { id: "app.gateway.access.column.userName", - defaultMessage: "User name", + defaultMessage: "Name", description: "Access table column for the user's display name.", }, accessUserId: { diff --git a/specs/platform/gateway-access-management.spec.md b/specs/platform/gateway-access-management.spec.md index 96dcf95e7..47f3c96d5 100644 --- a/specs/platform/gateway-access-management.spec.md +++ b/specs/platform/gateway-access-management.spec.md @@ -135,7 +135,7 @@ Each grant item SHALL expose: | `role_binding_id` | string | The backing RoleBinding id; informational only. Change (GAM-05) and revoke (GAM-06) are keyed by `user_id` in the URL and operate on all of that user's bindings on the gateway, not by `role_binding_id` | | `user_id` | string | HyperShell User id | | `username` | string | `User.username` (= Keycloak `preferred_username`); the console "User ID" column | -| `name` | string | `User.name` display name, when present; the console "User name" column | +| `name` | string | `User.name` display name, when present; the console "Name" column | | `email` | string | Optional | | `role` | string | `owner`, `admin`, or `user` (the console tier) mapping to `gateway:owner`, `gateway:admin`, `gateway:viewer` | | `is_creator` | boolean | `true` for the owner who created the gateway (informational only; see GAM-07) | diff --git a/specs/web-console/gateway-access-management.spec.md b/specs/web-console/gateway-access-management.spec.md index a8da2626e..dce7b8e13 100644 --- a/specs/web-console/gateway-access-management.spec.md +++ b/specs/web-console/gateway-access-management.spec.md @@ -39,7 +39,7 @@ Each row SHALL show: | Column | Source (GAM-03) | | --- | --- | -| User name | `name` (display name) | +| Name | `name` (display name) | | User ID | `username` | | Role | `role` (`owner`, `admin`, or `user`), rendered as an inline control per GAM-UI-05 | | (row action) | Remove access per GAM-UI-06 | From e094746337d21b2057ce2776899c4dbfb5d38c47 Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Wed, 7 Oct 2026 20:48:08 -0400 Subject: [PATCH 17/23] feat(access): confirm before a user changes their own role Changing your own access is easy to do by accident (e.g. demoting yourself), so the inline role control now opens a confirmation modal when the row being edited is the signed-in user. Changing another user's role is unchanged (applies immediately). Self-recognition is a client-side convenience: the host supplies the caller's identity through the gateway UI provider (the web console wires it from the BFF session), and AccessRoleControl compares it to the row's username. The server remains authoritative for the change. GAM-UI-05 updated with the behavior and scenarios. Co-Authored-By: Claude Opus 4.8 --- .../app/features/shell/application-shell.tsx | 7 + components/web-console/locales/en.json | 12 ++ .../src/access/access-page.test.tsx | 55 +++++++- .../src/access/access-row-actions.tsx | 129 ++++++++++++++---- .../src/gateway-ui-provider.tsx | 11 +- .../gateway-management-ui/src/messages.ts | 19 +++ .../gateway-access-management.spec.md | 14 ++ 7 files changed, 219 insertions(+), 28 deletions(-) diff --git a/components/web-console/app/features/shell/application-shell.tsx b/components/web-console/app/features/shell/application-shell.tsx index 73d6f89b9..d76daa664 100644 --- a/components/web-console/app/features/shell/application-shell.tsx +++ b/components/web-console/app/features/shell/application-shell.tsx @@ -35,6 +35,7 @@ import { messages } from "../../i18n/messages"; import productLogo from "../../../../../images/brand/logo.png"; import { useColorScheme } from "./use-color-scheme"; import { useRouteHeadingFocus } from "./route-focus"; +import { useSession } from "./use-session"; import { UserMenu } from "./user-menu"; import styles from "./application-shell.module.css"; @@ -58,6 +59,11 @@ export function ApplicationShell() { }), [navigate], ); + const { data: session } = useSession(); + const gatewayCurrentUser = useMemo( + () => ({ username: session?.user?.preferredUsername }), + [session?.user?.preferredUsername], + ); const { scheme, toggle: toggleColorScheme } = useColorScheme(); useRouteHeadingFocus(pathname); const segments = pathname.split("/").filter(Boolean); @@ -148,6 +154,7 @@ export function ApplicationShell() { navigation={dashboardNavigation} > diff --git a/components/web-console/locales/en.json b/components/web-console/locales/en.json index c0c5de917..cafa4ef8d 100644 --- a/components/web-console/locales/en.json +++ b/components/web-console/locales/en.json @@ -903,6 +903,18 @@ "defaultMessage": "Select a role", "description": "Label for the role radio group in the grant modal." }, + "app.gateway.access.selfRoleChange.body": { + "defaultMessage": "You are about to change your own role on this gateway to {role}. This may reduce your own access. Do you want to continue?", + "description": "Body of the confirmation modal shown when a user changes their own access role." + }, + "app.gateway.access.selfRoleChange.confirm": { + "defaultMessage": "Change my role", + "description": "Confirm button in the modal shown when a user changes their own access role." + }, + "app.gateway.access.selfRoleChange.title": { + "defaultMessage": "Change your own role?", + "description": "Title of the confirmation modal shown when a user changes their own access role." + }, "app.gateway.access.tab": { "defaultMessage": "Manage access", "description": "Label for the gateway detail Manage access tab." diff --git a/packages/gateway-management-ui/src/access/access-page.test.tsx b/packages/gateway-management-ui/src/access/access-page.test.tsx index 755b4f22e..8ff71a495 100644 --- a/packages/gateway-management-ui/src/access/access-page.test.tsx +++ b/packages/gateway-management-ui/src/access/access-page.test.tsx @@ -102,7 +102,7 @@ const operations: GatewayOperations = { searchGatewayDirectory: mocks.search, }; -function renderPage() { +function renderPage(currentUser?: { username?: string }) { const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false }, @@ -113,6 +113,7 @@ function renderPage() { { }); }); + it("confirms before a user changes their own role", async () => { + const user1 = userEvent.setup(); + // Two owners so the self owner row is editable (not the last owner), and the + // caller is the second owner. + mocks.list.mockResolvedValue( + accessPage([creator, secondOwner], ownerCapabilities), + ); + renderPage({ username: "owner2" }); + await screen.findByText("Owen Two"); + + await user1.click( + screen.getByRole("button", { name: "Change role: Owen Two" }), + ); + await user1.click(screen.getByRole("option", { name: "Admin" })); + + // The change is held until the user confirms in the modal. + const dialog = await screen.findByRole("dialog", { + name: "Change your own role?", + }); + expect(mocks.change).not.toHaveBeenCalled(); + + await user1.click( + within(dialog).getByRole("button", { name: "Change my role" }), + ); + await waitFor(() => { + expect(mocks.change).toHaveBeenCalledWith( + "gateway-1", + "u-owner-2", + "admin", + ); + }); + }); + + it("changes another user's role without a confirmation modal", async () => { + const user1 = userEvent.setup(); + mocks.list.mockResolvedValue(accessPage([user], ownerCapabilities)); + // The caller is someone other than the row being edited. + renderPage({ username: "owner2" }); + await screen.findByText("Uma User"); + + await user1.click( + screen.getByRole("button", { name: "Change role: Uma User" }), + ); + await user1.click(screen.getByRole("option", { name: "Admin" })); + await waitFor(() => { + expect(mocks.change).toHaveBeenCalledWith("gateway-1", "u-user", "admin"); + }); + expect( + screen.queryByRole("dialog", { name: "Change your own role?" }), + ).toBeNull(); + }); + it("removes access with confirmation", async () => { const user1 = userEvent.setup(); mocks.list.mockResolvedValue(accessPage([user], ownerCapabilities)); diff --git a/packages/gateway-management-ui/src/access/access-row-actions.tsx b/packages/gateway-management-ui/src/access/access-row-actions.tsx index fc89a19b2..e27d272b8 100644 --- a/packages/gateway-management-ui/src/access/access-row-actions.tsx +++ b/packages/gateway-management-ui/src/access/access-row-actions.tsx @@ -45,10 +45,18 @@ export function AccessRoleControl({ onActionError: (message: string | null) => void; }) { const intl = useIntl(); - const { gateways } = useGatewayUi(); + const { currentUser, gateways } = useGatewayUi(); const queryClient = useQueryClient(); + const confirmTitleId = useId(); + const confirmBodyId = useId(); const [isOpen, setIsOpen] = useState(false); + const [pendingSelfRole, setPendingSelfRole] = + useState(null); const roles = assignableRoles(capabilities); + // Changing your OWN access is easy to do by accident (e.g. demoting yourself), + // so a self role change is confirmed before it is applied. + const isSelf = + currentUser?.username?.toLowerCase() === grant.username.toLowerCase(); const mutation = useMutation({ mutationFn: (role: GatewayAccessRole) => gateways.changeGatewayAccessRole(gatewayId, grant.userId, role), @@ -97,31 +105,100 @@ export function AccessRoleControl({ ); return ( - + <> + + {pendingSelfRole ? ( + { + setPendingSelfRole(null); + } + } + variant={ModalVariant.small} + > + + +

+ {intl.formatMessage(messages.accessSelfRoleChangeBody, { + role: roleLabel(intl.formatMessage, pendingSelfRole), + })} +

+ {mutation.isError ? ( + + ) : null} +
+ + + + +
+ ) : null} + ); } diff --git a/packages/gateway-management-ui/src/gateway-ui-provider.tsx b/packages/gateway-management-ui/src/gateway-ui-provider.tsx index 4da96b335..ff8b779c5 100644 --- a/packages/gateway-management-ui/src/gateway-ui-provider.tsx +++ b/packages/gateway-management-ui/src/gateway-ui-provider.tsx @@ -22,7 +22,15 @@ export interface GatewayUiNavigation { ) => Promise | void; } +/** The signed-in user, supplied by the host so the package can recognize a + * caller acting on their own access row. Optional: when absent (e.g. no-auth + * mode) self-recognition is simply skipped. */ +export interface GatewayUiCurrentUser { + username?: string; +} + export interface GatewayUiServices { + currentUser?: GatewayUiCurrentUser; gateways: GatewayOperations; navigation: GatewayUiNavigation; } @@ -33,11 +41,12 @@ const GatewayUiContext = createContext( export function GatewayUiProvider({ children, + currentUser, gateways, navigation, }: PropsWithChildren) { return ( - + {children} ); diff --git a/packages/gateway-management-ui/src/messages.ts b/packages/gateway-management-ui/src/messages.ts index 729d8861b..84c0140ae 100644 --- a/packages/gateway-management-ui/src/messages.ts +++ b/packages/gateway-management-ui/src/messages.ts @@ -1215,6 +1215,25 @@ export const messages = defineMessages({ defaultMessage: "Change role", description: "Accessible label for the inline role control.", }, + accessSelfRoleChangeTitle: { + id: "app.gateway.access.selfRoleChange.title", + defaultMessage: "Change your own role?", + description: + "Title of the confirmation modal shown when a user changes their own access role.", + }, + accessSelfRoleChangeBody: { + id: "app.gateway.access.selfRoleChange.body", + defaultMessage: + "You are about to change your own role on this gateway to {role}. This may reduce your own access. Do you want to continue?", + description: + "Body of the confirmation modal shown when a user changes their own access role.", + }, + accessSelfRoleChangeConfirm: { + id: "app.gateway.access.selfRoleChange.confirm", + defaultMessage: "Change my role", + description: + "Confirm button in the modal shown when a user changes their own access role.", + }, accessRemove: { id: "app.gateway.access.remove", defaultMessage: "Remove access", diff --git a/specs/web-console/gateway-access-management.spec.md b/specs/web-console/gateway-access-management.spec.md index dce7b8e13..38796520b 100644 --- a/specs/web-console/gateway-access-management.spec.md +++ b/specs/web-console/gateway-access-management.spec.md @@ -95,6 +95,8 @@ Assigning a role to a newly selected user SHALL use a modal containing a **radio Changing an existing grant's role SHALL use the inline role control in that row (GAM-UI-02), offering the same role set the caller may assign. Selecting a different role SHALL change the user's access (GAM-05). The change SHALL reflect in the row on success. +When the caller changes **their own** role (the row's user is the signed-in user), the console SHALL require a confirmation modal before applying the change, warning that it alters the caller's own access, because self-demotion is easy to do by accident. Changing another user's role SHALL NOT require this confirmation. Self-recognition is a client-side convenience (the host supplies the caller's identity); the server remains authoritative for the change itself. + Role submissions SHALL disable their confirm/select control while in flight and SHALL surface a localized error (keeping the user's selection) on failure. #### Scenario: Owner assigns the Owner role via the radio modal @@ -109,6 +111,18 @@ Role submissions SHALL disable their confirm/select control while in flight and - GIVEN a non-owner admin opens the role modal or inline role control - THEN only **User** and **Admin** SHALL be selectable (no Owner option) +#### Scenario: Changing your own role requires confirmation + +- GIVEN an owner is viewing the access list and there is more than one owner +- WHEN that owner selects a different role on their own row +- THEN a confirmation modal SHALL appear and the change SHALL NOT be applied until confirmed +- AND confirming SHALL apply the change; cancelling SHALL leave the role unchanged + +#### Scenario: Changing another user's role does not require confirmation + +- GIVEN an owner selects a different role on another user's row +- THEN the change SHALL be applied without the self-change confirmation modal + #### Scenario: Change a role inline - GIVEN `dana` currently has **Admin** From 493635f853e070b14f8bd1c095c2ee2dbfa2348a Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Wed, 7 Oct 2026 20:58:19 -0400 Subject: [PATCH 18/23] feat(gateways): gray out "Rename gateway" for users who cannot edit Mirror the can_delete affordance for rename. The Gateway REST resource now also advertises a read-only, per-caller can_edit boolean, computed with the same check that authorizes PATCH (gateway:owner or gateway:admin; a gateway:viewer - a plain user - and a platform:admin without a per-gateway binding cannot edit). The console disables (not hides) the Rename action in both the row kebab and the detail header Actions dropdown with a tooltip explaining why, when can_edit is false, matching the disabled Delete affordance. The server stays authoritative on PATCH. Regenerated the Go/TS/Go-SDK OpenAPI artifacts; added rbac CanEditGateway unit test; updated the platform and web-console specs. Co-Authored-By: Claude Opus 4.8 --- .../api-server/openapi/openapi.gateways.yaml | 4 ++ .../pkg/api/openapi/api/openapi.yaml | 10 ++++ .../pkg/api/openapi/docs/Gateway.md | 26 +++++++++ .../pkg/api/openapi/model_gateway.go | 37 +++++++++++++ .../api-server/pkg/rbac/authorization.go | 9 +++ .../api-server/pkg/rbac/authorization_test.go | 55 +++++++++++++++++++ .../api-server/plugins/gateways/handler.go | 22 ++++++-- .../api-server/plugins/gateways/presenter.go | 3 +- components/sdk-go/client/agent_runtime_api.go | 2 +- components/sdk-go/client/client.go | 2 +- .../sdk-go/client/gateway_access_api.go | 2 +- components/sdk-go/client/gateway_api.go | 2 +- .../sdk-go/client/inference_route_api.go | 2 +- components/sdk-go/client/iterator.go | 2 +- .../sdk-go/client/managed_cluster_api.go | 2 +- .../open_shell_gateway_service_account_api.go | 2 +- .../sdk-go/client/provider_binding_api.go | 2 +- components/sdk-go/client/provider_spec_api.go | 2 +- components/sdk-go/client/role_api.go | 2 +- components/sdk-go/client/role_binding_api.go | 2 +- .../sdk-go/client/sandbox_template_api.go | 2 +- components/sdk-go/client/secret_source_api.go | 2 +- components/sdk-go/client/user_api.go | 2 +- components/sdk-go/types/agent_runtime.go | 2 +- components/sdk-go/types/base.go | 2 +- components/sdk-go/types/gateway.go | 3 +- components/sdk-go/types/gateway_access.go | 2 +- components/sdk-go/types/inference_route.go | 2 +- components/sdk-go/types/list_options.go | 2 +- components/sdk-go/types/managed_cluster.go | 2 +- .../open_shell_gateway_service_account.go | 2 +- components/sdk-go/types/provider_binding.go | 2 +- components/sdk-go/types/provider_spec.go | 2 +- components/sdk-go/types/role.go | 2 +- components/sdk-go/types/role_binding.go | 2 +- components/sdk-go/types/sandbox_template.go | 2 +- components/sdk-go/types/secret_source.go | 2 +- components/sdk-go/types/user.go | 2 +- .../sdk-typescript/src/agent_runtime.ts | 2 +- .../sdk-typescript/src/agent_runtime_api.ts | 2 +- components/sdk-typescript/src/base.ts | 2 +- components/sdk-typescript/src/client.ts | 2 +- components/sdk-typescript/src/gateway.ts | 3 +- .../sdk-typescript/src/gateway_access.ts | 2 +- .../sdk-typescript/src/gateway_access_api.ts | 2 +- components/sdk-typescript/src/gateway_api.ts | 2 +- components/sdk-typescript/src/index.ts | 2 +- .../sdk-typescript/src/inference_route.ts | 2 +- .../sdk-typescript/src/inference_route_api.ts | 2 +- .../sdk-typescript/src/managed_cluster.ts | 2 +- .../sdk-typescript/src/managed_cluster_api.ts | 2 +- .../src/open_shell_gateway_service_account.ts | 2 +- .../open_shell_gateway_service_account_api.ts | 2 +- .../sdk-typescript/src/provider_binding.ts | 2 +- .../src/provider_binding_api.ts | 2 +- .../sdk-typescript/src/provider_spec.ts | 2 +- .../sdk-typescript/src/provider_spec_api.ts | 2 +- components/sdk-typescript/src/role.ts | 2 +- components/sdk-typescript/src/role_api.ts | 2 +- components/sdk-typescript/src/role_binding.ts | 2 +- .../sdk-typescript/src/role_binding_api.ts | 2 +- .../sdk-typescript/src/sandbox_template.ts | 2 +- .../src/sandbox_template_api.ts | 2 +- .../sdk-typescript/src/secret_source.ts | 2 +- .../sdk-typescript/src/secret_source_api.ts | 2 +- components/sdk-typescript/src/user.ts | 2 +- components/sdk-typescript/src/user_api.ts | 2 +- .../adapters/api/gateway-operations.test.ts | 1 + .../app/adapters/api/gateway-operations.ts | 3 + components/web-console/locales/en.json | 4 ++ .../src/application/gateway-types.ts | 1 + .../src/gateways/gateway-connections.ts | 1 + .../src/gateways/gateway-data.ts | 3 + .../src/gateways/gateway-detail-header.tsx | 18 +++++- .../src/gateways/gateway-row-actions.tsx | 18 +++++- .../gateway-management-ui/src/messages.ts | 6 ++ .../gateway-access-management.spec.md | 11 ++++ specs/web-console/architecture.spec.md | 2 + 78 files changed, 285 insertions(+), 69 deletions(-) diff --git a/components/api-server/openapi/openapi.gateways.yaml b/components/api-server/openapi/openapi.gateways.yaml index c5117ba2b..f716c1ebe 100644 --- a/components/api-server/openapi/openapi.gateways.yaml +++ b/components/api-server/openapi/openapi.gateways.yaml @@ -329,6 +329,10 @@ components: type: boolean readOnly: true description: Whether the authenticated caller is authorized to delete this gateway (gateway owner or platform admin). Advertised so clients can disable a delete affordance the API would reject; the server remains authoritative. + can_edit: + type: boolean + readOnly: true + description: Whether the authenticated caller is authorized to modify this gateway (PATCH, e.g. rename) -- gateway owner or admin. Advertised so clients can disable an edit affordance the API would reject; the server remains authoritative. GatewayCreateRequest: type: object required: diff --git a/components/api-server/pkg/api/openapi/api/openapi.yaml b/components/api-server/pkg/api/openapi/api/openapi.yaml index 7fdbe2436..2302a5fc5 100644 --- a/components/api-server/pkg/api/openapi/api/openapi.yaml +++ b/components/api-server/pkg/api/openapi/api/openapi.yaml @@ -3340,6 +3340,13 @@ components: authoritative. readOnly: true type: boolean + can_edit: + description: "Whether the authenticated caller is authorized to modify\ + \ this gateway (PATCH, e.g. rename) -- gateway owner or admin. Advertised\ + \ so clients can disable an edit affordance the API would reject; the\ + \ server remains authoritative." + readOnly: true + type: boolean required: - cluster_id - name @@ -3372,6 +3379,7 @@ components: message: message kind: kind console_address: console_address + can_edit: true route_address: route_address oidc: oidc created_by: created_by @@ -3427,6 +3435,7 @@ components: message: message kind: kind console_address: console_address + can_edit: true route_address: route_address oidc: oidc created_by: created_by @@ -3463,6 +3472,7 @@ components: message: message kind: kind console_address: console_address + can_edit: true route_address: route_address oidc: oidc created_by: created_by diff --git a/components/api-server/pkg/api/openapi/docs/Gateway.md b/components/api-server/pkg/api/openapi/docs/Gateway.md index 74c4b07af..d0cb2543c 100644 --- a/components/api-server/pkg/api/openapi/docs/Gateway.md +++ b/components/api-server/pkg/api/openapi/docs/Gateway.md @@ -32,6 +32,7 @@ Name | Type | Description | Notes **Generation** | Pointer to **int64** | Monotonic desired-state revision, incremented by the API server on any desired-spec change | [optional] [readonly] **ObservedGeneration** | Pointer to **int64** | Generation the control plane last successfully applied; converged when equal to generation | [optional] [readonly] **CanDelete** | Pointer to **bool** | Whether the authenticated caller is authorized to delete this gateway (gateway owner or platform admin). Advertised so clients can disable a delete affordance the API would reject; the server remains authoritative. | [optional] [readonly] +**CanEdit** | Pointer to **bool** | Whether the authenticated caller is authorized to modify this gateway (PATCH, e.g. rename) -- gateway owner or admin. Advertised so clients can disable an edit affordance the API would reject; the server remains authoritative. | [optional] [readonly] ## Methods @@ -737,6 +738,31 @@ SetCanDelete sets CanDelete field to given value. HasCanDelete returns a boolean if a field has been set. +### GetCanEdit + +`func (o *Gateway) GetCanEdit() bool` + +GetCanEdit returns the CanEdit field if non-nil, zero value otherwise. + +### GetCanEditOk + +`func (o *Gateway) GetCanEditOk() (*bool, bool)` + +GetCanEditOk returns a tuple with the CanEdit field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetCanEdit + +`func (o *Gateway) SetCanEdit(v bool)` + +SetCanEdit sets CanEdit field to given value. + +### HasCanEdit + +`func (o *Gateway) HasCanEdit() bool` + +HasCanEdit returns a boolean if a field has been set. + [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/components/api-server/pkg/api/openapi/model_gateway.go b/components/api-server/pkg/api/openapi/model_gateway.go index 0fdf5dcb8..6827f8e67 100644 --- a/components/api-server/pkg/api/openapi/model_gateway.go +++ b/components/api-server/pkg/api/openapi/model_gateway.go @@ -66,6 +66,8 @@ type Gateway struct { ObservedGeneration *int64 `json:"observed_generation,omitempty"` // Whether the authenticated caller is authorized to delete this gateway (gateway owner or platform admin). Advertised so clients can disable a delete affordance the API would reject; the server remains authoritative. CanDelete *bool `json:"can_delete,omitempty"` + // Whether the authenticated caller is authorized to modify this gateway (PATCH, e.g. rename) -- gateway owner or admin. Advertised so clients can disable an edit affordance the API would reject; the server remains authoritative. + CanEdit *bool `json:"can_edit,omitempty"` } type _Gateway Gateway @@ -962,6 +964,38 @@ func (o *Gateway) SetCanDelete(v bool) { o.CanDelete = &v } +// GetCanEdit returns the CanEdit field value if set, zero value otherwise. +func (o *Gateway) GetCanEdit() bool { + if o == nil || IsNil(o.CanEdit) { + var ret bool + return ret + } + return *o.CanEdit +} + +// GetCanEditOk returns a tuple with the CanEdit field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *Gateway) GetCanEditOk() (*bool, bool) { + if o == nil || IsNil(o.CanEdit) { + return nil, false + } + return o.CanEdit, true +} + +// HasCanEdit returns a boolean if a field has been set. +func (o *Gateway) HasCanEdit() bool { + if o != nil && !IsNil(o.CanEdit) { + return true + } + + return false +} + +// SetCanEdit gets a reference to the given bool and assigns it to the CanEdit field. +func (o *Gateway) SetCanEdit(v bool) { + o.CanEdit = &v +} + func (o Gateway) MarshalJSON() ([]byte, error) { toSerialize, err := o.ToMap() if err != nil { @@ -1050,6 +1084,9 @@ func (o Gateway) ToMap() (map[string]interface{}, error) { if !IsNil(o.CanDelete) { toSerialize["can_delete"] = o.CanDelete } + if !IsNil(o.CanEdit) { + toSerialize["can_edit"] = o.CanEdit + } return toSerialize, nil } diff --git a/components/api-server/pkg/rbac/authorization.go b/components/api-server/pkg/rbac/authorization.go index 2885375ad..bd64ab5ed 100644 --- a/components/api-server/pkg/rbac/authorization.go +++ b/components/api-server/pkg/rbac/authorization.go @@ -451,6 +451,15 @@ func CanDeleteGateway(bindings []BindingSummary, gatewayID string) bool { return isGatewayAuthorized(http.MethodDelete, gatewayID, bindings) } +// CanEditGateway reports whether a caller holding bindings may modify (PATCH) the +// gateway, e.g. rename it. Like CanDeleteGateway it mirrors the enforced check +// (isGatewayAuthorized) so the Gateway.can_edit capability the REST API advertises +// never diverges from authorization: gateway:owner and gateway:admin may edit; +// gateway:viewer and a platform:admin without a per-gateway binding may not. +func CanEditGateway(bindings []BindingSummary, gatewayID string) bool { + return isGatewayAuthorized(http.MethodPatch, gatewayID, bindings) +} + func isGatewayAuthorized(method string, gatewayID string, bindings []BindingSummary) bool { if hasPlatformAdmin(bindings) && (method == http.MethodGet || method == http.MethodDelete) { return true diff --git a/components/api-server/pkg/rbac/authorization_test.go b/components/api-server/pkg/rbac/authorization_test.go index 897524c5f..84aebb33b 100644 --- a/components/api-server/pkg/rbac/authorization_test.go +++ b/components/api-server/pkg/rbac/authorization_test.go @@ -175,6 +175,61 @@ func TestCanDeleteGateway(t *testing.T) { } } +// TestCanEditGateway covers the Gateway.can_edit capability (PATCH, e.g. rename). +// Owners and granted admins may edit; viewers ("plain users") may not, and a +// platform admin without a per-gateway binding may not (PATCH is not in the +// platform-admin override). +func TestCanEditGateway(t *testing.T) { + gwID := "gw-aaa" + otherID := "gw-bbb" + ptr := func(s string) *string { return &s } + + tests := []struct { + name string + bindings []BindingSummary + want bool + }{ + { + name: "owner can edit", + bindings: []BindingSummary{{RoleName: "gateway:owner", Scope: "gateway", GatewayID: ptr(gwID)}}, + want: true, + }, + { + name: "granted admin can edit", + bindings: []BindingSummary{{RoleName: "gateway:admin", Scope: "gateway", GatewayID: ptr(gwID)}}, + want: true, + }, + { + name: "viewer (plain user) cannot edit", + bindings: []BindingSummary{{RoleName: "gateway:viewer", Scope: "gateway", GatewayID: ptr(gwID)}}, + want: false, + }, + { + name: "platform admin without a binding cannot edit", + bindings: []BindingSummary{{RoleName: "platform:admin", Scope: "global"}}, + want: false, + }, + { + name: "admin of a different gateway cannot edit", + bindings: []BindingSummary{{RoleName: "gateway:admin", Scope: "gateway", GatewayID: ptr(otherID)}}, + want: false, + }, + { + name: "no bindings cannot edit", + bindings: nil, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := CanEditGateway(tc.bindings, gwID); got != tc.want { + t.Errorf("CanEditGateway() = %v, want %v", got, tc.want) + } + }) + } +} + func TestIsAuthorized_NonCreatorCannotCreateGateways(t *testing.T) { gwID := "gw-aaa" bindings := []BindingSummary{ diff --git a/components/api-server/plugins/gateways/handler.go b/components/api-server/plugins/gateways/handler.go index 8a050b27b..f3e2ead9c 100644 --- a/components/api-server/plugins/gateways/handler.go +++ b/components/api-server/plugins/gateways/handler.go @@ -75,6 +75,18 @@ func (h gatewayHandler) callerCanDelete(bindings []rbac.BindingSummary, gatewayI return rbac.CanDeleteGateway(bindings, gatewayID) } +// callerCanEdit reports whether the caller holding bindings may modify (PATCH, +// e.g. rename) the gateway, mirroring the enforced check (rbac.CanEditGateway). +// Advertised as Gateway.can_edit so a client can disable an edit affordance the +// server would reject. When RBAC is not enforced every edit is permitted, so it +// reports true. +func (h gatewayHandler) callerCanEdit(bindings []rbac.BindingSummary, gatewayID string) bool { + if !h.enforceRBAC { + return true + } + return rbac.CanEditGateway(bindings, gatewayID) +} + // validateGatewayPhaseValue rejects a phase outside the canonical vocabulary. An // absent or empty phase is accepted so the field stays optional. func validateGatewayPhaseValue(phase *string) *errors.ServiceError { @@ -127,8 +139,8 @@ func (h gatewayHandler) Create(w http.ResponseWriter, r *http.Request) { } // The creator just received a gateway:owner binding (or RBAC is off), so - // they can always delete what they just created. - return PresentGateway(gatewayModel, "", true), nil + // they can always delete and edit what they just created. + return PresentGateway(gatewayModel, "", true, true), nil }, ErrorHandler: handlers.HandleError, } @@ -222,7 +234,7 @@ func (h gatewayHandler) Patch(w http.ResponseWriter, r *http.Request) { return nil, err } bindings := h.callerBindings(ctx) - return PresentGateway(gatewayModel, "", h.callerCanDelete(bindings, gatewayModel.ID)), nil + return PresentGateway(gatewayModel, "", h.callerCanDelete(bindings, gatewayModel.ID), h.callerCanEdit(bindings, gatewayModel.ID)), nil }, ErrorHandler: handlers.HandleError, } @@ -295,7 +307,7 @@ func (h gatewayHandler) List(w http.ResponseWriter, r *http.Request) { } bindings := h.callerBindings(ctx) for _, gateway := range gateways { - converted := PresentGateway(&gateway, ownerUsernames[gateway.ID], h.callerCanDelete(bindings, gateway.ID)) + converted := PresentGateway(&gateway, ownerUsernames[gateway.ID], h.callerCanDelete(bindings, gateway.ID), h.callerCanEdit(bindings, gateway.ID)) gatewayList.Items = append(gatewayList.Items, converted) } if listArgs.Fields != nil { @@ -338,7 +350,7 @@ func (h gatewayHandler) Get(w http.ResponseWriter, r *http.Request) { } bindings := h.callerBindings(ctx) - return PresentGateway(gateway, "", h.callerCanDelete(bindings, gateway.ID)), nil + return PresentGateway(gateway, "", h.callerCanDelete(bindings, gateway.ID), h.callerCanEdit(bindings, gateway.ID)), nil }, } diff --git a/components/api-server/plugins/gateways/presenter.go b/components/api-server/plugins/gateways/presenter.go index b7f09da39..45cb91047 100644 --- a/components/api-server/plugins/gateways/presenter.go +++ b/components/api-server/plugins/gateways/presenter.go @@ -32,10 +32,11 @@ func ConvertGateway(gateway openapi.GatewayCreateRequest) *Gateway { return c } -func PresentGateway(gateway *Gateway, createdBy string, canDelete bool) openapi.Gateway { +func PresentGateway(gateway *Gateway, createdBy string, canDelete bool, canEdit bool) openapi.Gateway { reference := presenters.PresentReference(gateway.ID, gateway) g := openapi.Gateway{ CanDelete: openapi.PtrBool(canDelete), + CanEdit: openapi.PtrBool(canEdit), Id: reference.Id, Kind: reference.Kind, Href: reference.Href, diff --git a/components/sdk-go/client/agent_runtime_api.go b/components/sdk-go/client/agent_runtime_api.go index a3b304d66..c17294045 100644 --- a/components/sdk-go/client/agent_runtime_api.go +++ b/components/sdk-go/client/agent_runtime_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package client diff --git a/components/sdk-go/client/client.go b/components/sdk-go/client/client.go index 3c318eaa5..018f3d212 100644 --- a/components/sdk-go/client/client.go +++ b/components/sdk-go/client/client.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package client diff --git a/components/sdk-go/client/gateway_access_api.go b/components/sdk-go/client/gateway_access_api.go index 740dabea1..f3a69877c 100644 --- a/components/sdk-go/client/gateway_access_api.go +++ b/components/sdk-go/client/gateway_access_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package client diff --git a/components/sdk-go/client/gateway_api.go b/components/sdk-go/client/gateway_api.go index 442509107..09836e6c1 100644 --- a/components/sdk-go/client/gateway_api.go +++ b/components/sdk-go/client/gateway_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package client diff --git a/components/sdk-go/client/inference_route_api.go b/components/sdk-go/client/inference_route_api.go index 29b70e99a..93143f44e 100644 --- a/components/sdk-go/client/inference_route_api.go +++ b/components/sdk-go/client/inference_route_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package client diff --git a/components/sdk-go/client/iterator.go b/components/sdk-go/client/iterator.go index e8f80ae30..d78859efc 100644 --- a/components/sdk-go/client/iterator.go +++ b/components/sdk-go/client/iterator.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package client diff --git a/components/sdk-go/client/managed_cluster_api.go b/components/sdk-go/client/managed_cluster_api.go index 8d4a89e51..864951044 100644 --- a/components/sdk-go/client/managed_cluster_api.go +++ b/components/sdk-go/client/managed_cluster_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package client diff --git a/components/sdk-go/client/open_shell_gateway_service_account_api.go b/components/sdk-go/client/open_shell_gateway_service_account_api.go index e28933a1d..4925fa0a4 100644 --- a/components/sdk-go/client/open_shell_gateway_service_account_api.go +++ b/components/sdk-go/client/open_shell_gateway_service_account_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package client diff --git a/components/sdk-go/client/provider_binding_api.go b/components/sdk-go/client/provider_binding_api.go index 713fc00c8..e15c4672f 100644 --- a/components/sdk-go/client/provider_binding_api.go +++ b/components/sdk-go/client/provider_binding_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package client diff --git a/components/sdk-go/client/provider_spec_api.go b/components/sdk-go/client/provider_spec_api.go index 9f1e8edf0..e70dc36cc 100644 --- a/components/sdk-go/client/provider_spec_api.go +++ b/components/sdk-go/client/provider_spec_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package client diff --git a/components/sdk-go/client/role_api.go b/components/sdk-go/client/role_api.go index e52566864..b17cd9f53 100644 --- a/components/sdk-go/client/role_api.go +++ b/components/sdk-go/client/role_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package client diff --git a/components/sdk-go/client/role_binding_api.go b/components/sdk-go/client/role_binding_api.go index a45fc5a71..0babdd17c 100644 --- a/components/sdk-go/client/role_binding_api.go +++ b/components/sdk-go/client/role_binding_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package client diff --git a/components/sdk-go/client/sandbox_template_api.go b/components/sdk-go/client/sandbox_template_api.go index a9a9c6496..d040a4ea0 100644 --- a/components/sdk-go/client/sandbox_template_api.go +++ b/components/sdk-go/client/sandbox_template_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package client diff --git a/components/sdk-go/client/secret_source_api.go b/components/sdk-go/client/secret_source_api.go index 60d009bd9..fe102b02a 100644 --- a/components/sdk-go/client/secret_source_api.go +++ b/components/sdk-go/client/secret_source_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package client diff --git a/components/sdk-go/client/user_api.go b/components/sdk-go/client/user_api.go index aae430f77..f63f4da6a 100644 --- a/components/sdk-go/client/user_api.go +++ b/components/sdk-go/client/user_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package client diff --git a/components/sdk-go/types/agent_runtime.go b/components/sdk-go/types/agent_runtime.go index c663532e1..e0fd589ff 100644 --- a/components/sdk-go/types/agent_runtime.go +++ b/components/sdk-go/types/agent_runtime.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package types diff --git a/components/sdk-go/types/base.go b/components/sdk-go/types/base.go index ac47fc893..524448563 100644 --- a/components/sdk-go/types/base.go +++ b/components/sdk-go/types/base.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package types diff --git a/components/sdk-go/types/gateway.go b/components/sdk-go/types/gateway.go index 8dc102214..57f59f6af 100644 --- a/components/sdk-go/types/gateway.go +++ b/components/sdk-go/types/gateway.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package types @@ -14,6 +14,7 @@ type Gateway struct { ActiveSandboxCount int32 `json:"active_sandbox_count,omitempty"` CanDelete bool `json:"can_delete,omitempty"` + CanEdit bool `json:"can_edit,omitempty"` ClusterID string `json:"cluster_id"` ConsoleAddress string `json:"console_address,omitempty"` CreatedBy string `json:"created_by,omitempty"` diff --git a/components/sdk-go/types/gateway_access.go b/components/sdk-go/types/gateway_access.go index 42bc49431..52b4f0696 100644 --- a/components/sdk-go/types/gateway_access.go +++ b/components/sdk-go/types/gateway_access.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package types diff --git a/components/sdk-go/types/inference_route.go b/components/sdk-go/types/inference_route.go index b3a154340..c0cf6bcf1 100644 --- a/components/sdk-go/types/inference_route.go +++ b/components/sdk-go/types/inference_route.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package types diff --git a/components/sdk-go/types/list_options.go b/components/sdk-go/types/list_options.go index 8790d08fe..5969889b5 100644 --- a/components/sdk-go/types/list_options.go +++ b/components/sdk-go/types/list_options.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package types diff --git a/components/sdk-go/types/managed_cluster.go b/components/sdk-go/types/managed_cluster.go index 2cfaf9648..49de51cd4 100644 --- a/components/sdk-go/types/managed_cluster.go +++ b/components/sdk-go/types/managed_cluster.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package types diff --git a/components/sdk-go/types/open_shell_gateway_service_account.go b/components/sdk-go/types/open_shell_gateway_service_account.go index ff9769a1e..684aec41d 100644 --- a/components/sdk-go/types/open_shell_gateway_service_account.go +++ b/components/sdk-go/types/open_shell_gateway_service_account.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package types diff --git a/components/sdk-go/types/provider_binding.go b/components/sdk-go/types/provider_binding.go index c0a56e00d..fbe7d40c3 100644 --- a/components/sdk-go/types/provider_binding.go +++ b/components/sdk-go/types/provider_binding.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package types diff --git a/components/sdk-go/types/provider_spec.go b/components/sdk-go/types/provider_spec.go index 59b24e043..9bad108fc 100644 --- a/components/sdk-go/types/provider_spec.go +++ b/components/sdk-go/types/provider_spec.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package types diff --git a/components/sdk-go/types/role.go b/components/sdk-go/types/role.go index af4022871..56b3a63b6 100644 --- a/components/sdk-go/types/role.go +++ b/components/sdk-go/types/role.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package types diff --git a/components/sdk-go/types/role_binding.go b/components/sdk-go/types/role_binding.go index abd44f7dd..a552df839 100644 --- a/components/sdk-go/types/role_binding.go +++ b/components/sdk-go/types/role_binding.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package types diff --git a/components/sdk-go/types/sandbox_template.go b/components/sdk-go/types/sandbox_template.go index d15063be8..c3df6c890 100644 --- a/components/sdk-go/types/sandbox_template.go +++ b/components/sdk-go/types/sandbox_template.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package types diff --git a/components/sdk-go/types/secret_source.go b/components/sdk-go/types/secret_source.go index 41de72e9a..ffd566738 100644 --- a/components/sdk-go/types/secret_source.go +++ b/components/sdk-go/types/secret_source.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package types diff --git a/components/sdk-go/types/user.go b/components/sdk-go/types/user.go index 0b60aac72..e6943d71b 100644 --- a/components/sdk-go/types/user.go +++ b/components/sdk-go/types/user.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 package types diff --git a/components/sdk-typescript/src/agent_runtime.ts b/components/sdk-typescript/src/agent_runtime.ts index 5e4720278..9270a1ab5 100644 --- a/components/sdk-typescript/src/agent_runtime.ts +++ b/components/sdk-typescript/src/agent_runtime.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/agent_runtime_api.ts b/components/sdk-typescript/src/agent_runtime_api.ts index 84d402aff..ad337a295 100644 --- a/components/sdk-typescript/src/agent_runtime_api.ts +++ b/components/sdk-typescript/src/agent_runtime_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/base.ts b/components/sdk-typescript/src/base.ts index 7abd507b5..7d5bb930d 100644 --- a/components/sdk-typescript/src/base.ts +++ b/components/sdk-typescript/src/base.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 export type ObjectReference = { id: string; diff --git a/components/sdk-typescript/src/client.ts b/components/sdk-typescript/src/client.ts index 7c23367d9..51697e1d1 100644 --- a/components/sdk-typescript/src/client.ts +++ b/components/sdk-typescript/src/client.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { SDKClientConfig } from './base.js'; import { AgentRuntimeAPI } from './agent_runtime_api.js'; diff --git a/components/sdk-typescript/src/gateway.ts b/components/sdk-typescript/src/gateway.ts index 10fc4e43e..3c6dcd1df 100644 --- a/components/sdk-typescript/src/gateway.ts +++ b/components/sdk-typescript/src/gateway.ts @@ -1,12 +1,13 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { ObjectReference, ListMeta } from './base.js'; export type Gateway = ObjectReference & { active_sandbox_count: number; can_delete: boolean; + can_edit: boolean; cluster_id: string; console_address: string; created_by: string; diff --git a/components/sdk-typescript/src/gateway_access.ts b/components/sdk-typescript/src/gateway_access.ts index f4208d6b4..627bdf386 100644 --- a/components/sdk-typescript/src/gateway_access.ts +++ b/components/sdk-typescript/src/gateway_access.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 export type GatewayAccessCapabilities = { diff --git a/components/sdk-typescript/src/gateway_access_api.ts b/components/sdk-typescript/src/gateway_access_api.ts index 312e16fdb..45176fe00 100644 --- a/components/sdk-typescript/src/gateway_access_api.ts +++ b/components/sdk-typescript/src/gateway_access_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { SDKClientConfig, RequestOptions } from './base.js'; import { sdkFetch } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_api.ts b/components/sdk-typescript/src/gateway_api.ts index 4d468c2da..588df65c6 100644 --- a/components/sdk-typescript/src/gateway_api.ts +++ b/components/sdk-typescript/src/gateway_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/index.ts b/components/sdk-typescript/src/index.ts index d51632edb..3f41528de 100644 --- a/components/sdk-typescript/src/index.ts +++ b/components/sdk-typescript/src/index.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 export { SDKClient } from './client.js'; export type { SDKClientConfig, ListOptions, RequestOptions, ObjectReference, ListMeta, APIError } from './base.js'; diff --git a/components/sdk-typescript/src/inference_route.ts b/components/sdk-typescript/src/inference_route.ts index d0a261577..1fc3751e0 100644 --- a/components/sdk-typescript/src/inference_route.ts +++ b/components/sdk-typescript/src/inference_route.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/inference_route_api.ts b/components/sdk-typescript/src/inference_route_api.ts index 7c74bf155..a3af34882 100644 --- a/components/sdk-typescript/src/inference_route_api.ts +++ b/components/sdk-typescript/src/inference_route_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/managed_cluster.ts b/components/sdk-typescript/src/managed_cluster.ts index e50bf5ddb..3c21f3e76 100644 --- a/components/sdk-typescript/src/managed_cluster.ts +++ b/components/sdk-typescript/src/managed_cluster.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/managed_cluster_api.ts b/components/sdk-typescript/src/managed_cluster_api.ts index d5bd0e6ee..89bc27fff 100644 --- a/components/sdk-typescript/src/managed_cluster_api.ts +++ b/components/sdk-typescript/src/managed_cluster_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/open_shell_gateway_service_account.ts b/components/sdk-typescript/src/open_shell_gateway_service_account.ts index 6d1ad7758..4df16679c 100644 --- a/components/sdk-typescript/src/open_shell_gateway_service_account.ts +++ b/components/sdk-typescript/src/open_shell_gateway_service_account.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 export type OpenShellGatewayServiceAccountCapabilities = { diff --git a/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts b/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts index 5be8a4716..4c78d7e6a 100644 --- a/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts +++ b/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { SDKClientConfig, RequestOptions } from './base.js'; import { sdkFetch } from './base.js'; diff --git a/components/sdk-typescript/src/provider_binding.ts b/components/sdk-typescript/src/provider_binding.ts index 5fb88b316..7da64d58a 100644 --- a/components/sdk-typescript/src/provider_binding.ts +++ b/components/sdk-typescript/src/provider_binding.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/provider_binding_api.ts b/components/sdk-typescript/src/provider_binding_api.ts index 391421e0d..3ab4a4999 100644 --- a/components/sdk-typescript/src/provider_binding_api.ts +++ b/components/sdk-typescript/src/provider_binding_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/provider_spec.ts b/components/sdk-typescript/src/provider_spec.ts index cbd24e35a..cf2bb8315 100644 --- a/components/sdk-typescript/src/provider_spec.ts +++ b/components/sdk-typescript/src/provider_spec.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/provider_spec_api.ts b/components/sdk-typescript/src/provider_spec_api.ts index 0345282ea..af6a6619b 100644 --- a/components/sdk-typescript/src/provider_spec_api.ts +++ b/components/sdk-typescript/src/provider_spec_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/role.ts b/components/sdk-typescript/src/role.ts index 8aea439d2..6343bc7d8 100644 --- a/components/sdk-typescript/src/role.ts +++ b/components/sdk-typescript/src/role.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/role_api.ts b/components/sdk-typescript/src/role_api.ts index a3ea2138d..b420e453f 100644 --- a/components/sdk-typescript/src/role_api.ts +++ b/components/sdk-typescript/src/role_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/role_binding.ts b/components/sdk-typescript/src/role_binding.ts index 49bd4ea3a..5b5939979 100644 --- a/components/sdk-typescript/src/role_binding.ts +++ b/components/sdk-typescript/src/role_binding.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/role_binding_api.ts b/components/sdk-typescript/src/role_binding_api.ts index 26deccd44..3db5fd77f 100644 --- a/components/sdk-typescript/src/role_binding_api.ts +++ b/components/sdk-typescript/src/role_binding_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/sandbox_template.ts b/components/sdk-typescript/src/sandbox_template.ts index bb3426619..bfc17ebdd 100644 --- a/components/sdk-typescript/src/sandbox_template.ts +++ b/components/sdk-typescript/src/sandbox_template.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/sandbox_template_api.ts b/components/sdk-typescript/src/sandbox_template_api.ts index ebf6764e1..e1f05f648 100644 --- a/components/sdk-typescript/src/sandbox_template_api.ts +++ b/components/sdk-typescript/src/sandbox_template_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/secret_source.ts b/components/sdk-typescript/src/secret_source.ts index 79c8c04d8..cdf430c4b 100644 --- a/components/sdk-typescript/src/secret_source.ts +++ b/components/sdk-typescript/src/secret_source.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/secret_source_api.ts b/components/sdk-typescript/src/secret_source_api.ts index 23c23cd82..2fd1c32d5 100644 --- a/components/sdk-typescript/src/secret_source_api.ts +++ b/components/sdk-typescript/src/secret_source_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/user.ts b/components/sdk-typescript/src/user.ts index c38e3724b..638c22ecd 100644 --- a/components/sdk-typescript/src/user.ts +++ b/components/sdk-typescript/src/user.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/user_api.ts b/components/sdk-typescript/src/user_api.ts index 50c74043d..d51641a5a 100644 --- a/components/sdk-typescript/src/user_api.ts +++ b/components/sdk-typescript/src/user_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: b5e48ea927b7b31522685f4f6ed199d4f74bc7c5ca6e148f999ad1e4bde0ebb0 +// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/web-console/app/adapters/api/gateway-operations.test.ts b/components/web-console/app/adapters/api/gateway-operations.test.ts index 0df15c581..fedfb0180 100644 --- a/components/web-console/app/adapters/api/gateway-operations.test.ts +++ b/components/web-console/app/adapters/api/gateway-operations.test.ts @@ -60,6 +60,7 @@ function gateway(overrides: Partial = {}): Gateway { return { active_sandbox_count: 0, can_delete: false, + can_edit: false, cluster_id: "", console_address: "", created_at: null, diff --git a/components/web-console/app/adapters/api/gateway-operations.ts b/components/web-console/app/adapters/api/gateway-operations.ts index f51cc8e58..8596e3069 100644 --- a/components/web-console/app/adapters/api/gateway-operations.ts +++ b/components/web-console/app/adapters/api/gateway-operations.ts @@ -184,6 +184,9 @@ function toGatewayRecord(gateway: Gateway): GatewayRecord { ...(typeof gateway.can_delete === "boolean" ? { canDelete: gateway.can_delete } : {}), + ...(typeof gateway.can_edit === "boolean" + ? { canEdit: gateway.can_edit } + : {}), clusterId: gateway.cluster_id, ...(consoleUrl ? { consoleUrl } : {}), ...(gateway.created_at ? { createdAt: gateway.created_at } : {}), diff --git a/components/web-console/locales/en.json b/components/web-console/locales/en.json index cafa4ef8d..36f68e317 100644 --- a/components/web-console/locales/en.json +++ b/components/web-console/locales/en.json @@ -1267,6 +1267,10 @@ "defaultMessage": "Gateway could not be renamed", "description": "Title shown when gateway renaming fails." }, + "app.gateway.rename.notPermitted": { + "defaultMessage": "Only a gateway owner or admin can rename this gateway.", + "description": "Tooltip explaining why the Rename gateway action is disabled for a caller who lacks edit permission." + }, "app.gateway.rename.pending": { "defaultMessage": "Renaming gateway", "description": "Accessible progress text while a gateway is being renamed." diff --git a/packages/gateway-management-ui/src/application/gateway-types.ts b/packages/gateway-management-ui/src/application/gateway-types.ts index 66deb8627..cba2da12e 100644 --- a/packages/gateway-management-ui/src/application/gateway-types.ts +++ b/packages/gateway-management-ui/src/application/gateway-types.ts @@ -10,6 +10,7 @@ export interface ProvisioningCondition { export interface GatewayRecord { activeSandboxCount?: number; canDelete?: boolean; + canEdit?: boolean; clusterId: string; consoleUrl?: string; createdAt?: string; diff --git a/packages/gateway-management-ui/src/gateways/gateway-connections.ts b/packages/gateway-management-ui/src/gateways/gateway-connections.ts index a2e9c22ae..8df8ef936 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-connections.ts +++ b/packages/gateway-management-ui/src/gateways/gateway-connections.ts @@ -3,6 +3,7 @@ import { gatewayCanonicalPhases } from "./gateway-data"; export interface GatewayConnection { activeSandboxCount?: number; canDelete?: boolean; + canEdit?: boolean; clusterId: string; clusterName: string; consoleUrl?: string; diff --git a/packages/gateway-management-ui/src/gateways/gateway-data.ts b/packages/gateway-management-ui/src/gateways/gateway-data.ts index 648d7410e..7685f51a1 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-data.ts +++ b/packages/gateway-management-ui/src/gateways/gateway-data.ts @@ -381,6 +381,9 @@ export function toGatewayConnection( ...(typeof gateway.canDelete === "boolean" ? { canDelete: gateway.canDelete } : {}), + ...(typeof gateway.canEdit === "boolean" + ? { canEdit: gateway.canEdit } + : {}), clusterId, // The name is resolved from the ManagedCluster the id references. clusterName: "", diff --git a/packages/gateway-management-ui/src/gateways/gateway-detail-header.tsx b/packages/gateway-management-ui/src/gateways/gateway-detail-header.tsx index 4c21c9053..b35a9f87f 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-detail-header.tsx +++ b/packages/gateway-management-ui/src/gateways/gateway-detail-header.tsx @@ -119,10 +119,11 @@ function GatewayDetailActions({ const [isActionsOpen, setIsActionsOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isRenameOpen, setIsRenameOpen] = useState(false); - // Disable delete only when the server has explicitly told us the caller cannot - // delete. An absent capability (older server) leaves it enabled; the API 403 + // Disable delete/rename only when the server has explicitly told us the caller + // cannot. An absent capability (older server) leaves it enabled; the API 403 // remains authoritative either way. const canDelete = gateway.canDelete !== false; + const canEdit = gateway.canEdit !== false; return ( <> @@ -192,9 +193,22 @@ function GatewayDetailActions({ > { + if (!canEdit) { + return; + } setIsRenameOpen(true); }} + tooltipProps={ + canEdit + ? undefined + : { + content: intl.formatMessage( + messages.renameGatewayNotPermitted, + ), + } + } > diff --git a/packages/gateway-management-ui/src/gateways/gateway-row-actions.tsx b/packages/gateway-management-ui/src/gateways/gateway-row-actions.tsx index 468ef82f2..b07230aaa 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-row-actions.tsx +++ b/packages/gateway-management-ui/src/gateways/gateway-row-actions.tsx @@ -35,10 +35,11 @@ export function GatewayRowActions({ const [isRenameOpen, setIsRenameOpen] = useState(false); const [copyResult, setCopyResult] = useState<"error" | "success">(); const connectionCommand = buildGatewayAddCommand(gateway); - // Disable delete only when the server has explicitly told us the caller cannot - // delete. An absent capability (older server) leaves it enabled; the API 403 + // Disable delete/rename only when the server has explicitly told us the caller + // cannot. An absent capability (older server) leaves it enabled; the API 403 // remains authoritative either way. const canDelete = gateway.canDelete !== false; + const canEdit = gateway.canEdit !== false; const copyConnectionCommand = async () => { if (!connectionCommand) { @@ -99,9 +100,22 @@ export function GatewayRowActions({
) : null} { + if (!canEdit) { + return; + } setIsRenameOpen(true); }} + tooltipProps={ + canEdit + ? undefined + : { + content: intl.formatMessage( + messages.renameGatewayNotPermitted, + ), + } + } > diff --git a/packages/gateway-management-ui/src/messages.ts b/packages/gateway-management-ui/src/messages.ts index 84c0140ae..79456b2ea 100644 --- a/packages/gateway-management-ui/src/messages.ts +++ b/packages/gateway-management-ui/src/messages.ts @@ -542,6 +542,12 @@ export const messages = defineMessages({ defaultMessage: "Rename gateway", description: "Action that changes a gateway's name.", }, + renameGatewayNotPermitted: { + id: "app.gateway.rename.notPermitted", + defaultMessage: "Only a gateway owner or admin can rename this gateway.", + description: + "Tooltip explaining why the Rename gateway action is disabled for a caller who lacks edit permission.", + }, renameGatewayTitle: { id: "app.gateway.rename.title", defaultMessage: "Rename {gatewayName}", diff --git a/specs/platform/gateway-access-management.spec.md b/specs/platform/gateway-access-management.spec.md index 47f3c96d5..2a02690e4 100644 --- a/specs/platform/gateway-access-management.spec.md +++ b/specs/platform/gateway-access-management.spec.md @@ -59,6 +59,8 @@ The platform SHALL define a per-gateway built-in role `gateway:admin` representi The Gateway REST resource SHALL advertise a read-only, per-caller `can_delete` boolean on `GET` (list and single) responses, computed from the authenticated caller's current bindings with the **same check that authorizes `DELETE`** (`gateway:owner` on that gateway, or `platform:admin`). `can_delete` is advisory: it exists so clients (console, CLI) can disable a delete affordance the API would reject, and SHALL NOT weaken enforcement. The API server SHALL remain the authorization boundary and SHALL enforce delete authorization on `DELETE` regardless of `can_delete`. When RBAC enforcement is disabled, every delete is permitted, so `can_delete` SHALL be `true`. +The Gateway REST resource SHALL likewise advertise a read-only, per-caller `can_edit` boolean, computed from the **same check that authorizes `PATCH`** (`gateway:owner` or `gateway:admin` on that gateway; a `gateway:viewer` and a `platform:admin` without a per-gateway binding may not edit). It is advisory in the same way (clients disable an edit/rename affordance the API would reject) and SHALL NOT weaken enforcement; the server stays authoritative on `PATCH`. When RBAC enforcement is disabled, `can_edit` SHALL be `true`. + `gateway:admin` SHALL be stored and granted exactly like other per-gateway bindings: a `RoleBinding` with `scope=gateway` and a `gateway_id`. It SHALL NOT be a JWT-synced role and SHALL NOT be assignable via Keycloak realm roles. A user holding `gateway:admin` on a gateway SHALL see that gateway in list and get results (gateway visibility follows any per-gateway binding). @@ -86,6 +88,15 @@ A user holding `gateway:admin` on a gateway SHALL see that gateway in list and g - WHEN user C (a `gateway:owner`) or a `platform:admin` calls `GET /api/hypershell/v1/gateways/gw-1` - THEN the response `can_delete` SHALL be `true` +#### Scenario: can_edit reflects the caller's edit authorization + +- GIVEN user V holds `gateway:viewer` on gw-1 +- WHEN user V calls `GET /api/hypershell/v1/gateways/gw-1` +- THEN the response `can_edit` SHALL be `false` +- AND a `PATCH /api/hypershell/v1/gateways/gw-1` by user V SHALL return `403` +- WHEN a `gateway:owner` or `gateway:admin` of gw-1 calls `GET /api/hypershell/v1/gateways/gw-1` +- THEN the response `can_edit` SHALL be `true` + --- ### Requirement: GAM-02 -- Keycloak Role Bridge Mapping diff --git a/specs/web-console/architecture.spec.md b/specs/web-console/architecture.spec.md index 7457b6744..cf5201c2b 100644 --- a/specs/web-console/architecture.spec.md +++ b/specs/web-console/architecture.spec.md @@ -350,6 +350,8 @@ The gateway landing page SHALL make the shortest useful OpenShell workflow avail Gateway renaming SHALL use the existing `PATCH /gateways/{id}` contract and send only the trimmed `name` field. Both rename entry points SHALL use the same required-field validation, prevent unchanged or duplicate submission, preserve user input with recovery guidance on failure, update gateway detail and breadcrumb cache state, invalidate the collection, and provide visible success feedback. +Both rename entry points (the row actions menu and the detail header Actions dropdown) SHALL disable the rename action -- rather than hide it -- with an accessible explanation (tooltip) when the gateway's advertised `can_edit` capability is false (the caller is a plain user, i.e. not a `gateway:owner` or `gateway:admin`; see platform `gateway-access-management.spec.md` GAM-01). A gateway whose `can_edit` is absent or true SHALL leave the action enabled. This mirrors the server authorization; the API remains authoritative and SHALL still reject an unauthorized `PATCH` with `403`. + Gateway deletion SHALL use the existing `DELETE /gateways/{id}` contract. Both delete entry points SHALL present the same explicit confirmation before commitment, prevent duplicate submission while deletion is pending, preserve the confirmation with recovery guidance on failure, and provide an accurate success notification after deletion. Successful deletion from a detail page SHALL return the user to the gateway collection and invalidate both collection and deleted-detail query state. Both delete entry points (the row actions menu and the detail header Actions dropdown) SHALL disable the delete action -- rather than hide it -- with an accessible explanation when the gateway's advertised `can_delete` capability is false (the caller is neither a `gateway:owner` nor a `platform:admin`; see platform `gateway-access-management.spec.md` GAM-01). A gateway whose `can_delete` is absent or true SHALL leave the action enabled. This mirrors the server authorization rather than replacing it; the API remains authoritative and SHALL still reject an unauthorized `DELETE` with `403`. From 41e0a9aa725485bcc8cc9d4689fb40c5e8a36103 Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Thu, 8 Oct 2026 16:23:06 -0400 Subject: [PATCH 19/23] fix(access): fast-retry directory projection while empty at startup The Keycloak realm directory projection did one eager refresh at control-plane startup and then only refreshed every 5 minutes. On a fresh deploy Keycloak has not finished importing realm users yet, so that first refresh caches an empty snapshot and the console "Add users" picker returns no users for a full refresh interval. Fast-poll at a shorter interval while the snapshot is empty, then settle into the normal cadence once populated. Co-Authored-By: Claude Opus 4.8 --- .../internal/directory/directory.go | 20 ++++++++++ .../internal/directory/directory_test.go | 38 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/components/control-plane/internal/directory/directory.go b/components/control-plane/internal/directory/directory.go index 2483d5e5b..6c072bf41 100644 --- a/components/control-plane/internal/directory/directory.go +++ b/components/control-plane/internal/directory/directory.go @@ -22,6 +22,11 @@ const ( defaultSearchLimit = 50 ) +// startupRetryInterval is the cadence used while the snapshot is still empty at +// startup (e.g. the Keycloak realm import has not finished when the control +// plane comes up). It is a var so tests can shrink it. +var startupRetryInterval = 10 * time.Second + // lister is the slice of the Keycloak client the projection depends on. type lister interface { ListRealmUsers(ctx context.Context) ([]keycloak.RealmUser, error) @@ -44,9 +49,24 @@ func NewProjection(l lister, interval time.Duration) *Projection { } // Run refreshes immediately, then on the configured interval until ctx is done. +// While the snapshot is still empty it retries on the shorter startupRetryInterval +// so the console "Add users" picker is not blank for a full refresh interval after +// a fresh deploy, when Keycloak may not have finished importing the realm yet. func (p *Projection) Run(ctx context.Context) error { log.Printf("INFO keycloak directory projection started (interval=%s)", p.interval) p.refreshOnce(ctx) + // ponytail: fast-poll while empty; a realm that is genuinely empty keeps + // polling at startupRetryInterval forever rather than falling back to the + // steady interval - fine, deployed realms always have users, and the first + // one then shows up within startupRetryInterval instead of p.interval. + for len(p.snapshot()) == 0 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(startupRetryInterval): + p.refreshOnce(ctx) + } + } ticker := time.NewTicker(p.interval) defer ticker.Stop() for { diff --git a/components/control-plane/internal/directory/directory_test.go b/components/control-plane/internal/directory/directory_test.go index 0ec95fa47..1a71160ae 100644 --- a/components/control-plane/internal/directory/directory_test.go +++ b/components/control-plane/internal/directory/directory_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" "github.com/openshift-online/hypershell/components/control-plane/internal/keycloak" ) @@ -56,6 +57,43 @@ func TestResolve_ExactCaseInsensitive(t *testing.T) { } } +// emptyThenPopulated returns no users on the first call (Keycloak realm import +// not finished) and one user afterwards. +type emptyThenPopulated struct{ calls int } + +func (l *emptyThenPopulated) ListRealmUsers(context.Context) ([]keycloak.RealmUser, error) { + l.calls++ + if l.calls == 1 { + return nil, nil + } + return []keycloak.RealmUser{{Username: "alice"}}, nil +} + +// Run must refill the snapshot via the startup fast-retry when the first refresh +// came back empty, without waiting for the (here: very long) steady interval. +func TestRun_FastRetriesWhileEmpty(t *testing.T) { + old := startupRetryInterval + startupRetryInterval = time.Millisecond + defer func() { startupRetryInterval = old }() + + p := NewProjection(&emptyThenPopulated{}, time.Hour) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { _ = p.Run(ctx) }() + + deadline := time.After(2 * time.Second) + for { + if _, ok := p.Resolve("alice"); ok { + return + } + select { + case <-deadline: + t.Fatal("snapshot stayed empty; startup fast-retry did not refill after the realm became ready") + case <-time.After(time.Millisecond): + } + } +} + func TestRefresh_KeepsPreviousSnapshotOnError(t *testing.T) { p := seeded() p.lister = fakeLister{err: errors.New("keycloak down")} From 090848084ccfc2dfe03632c7261cd123967d2e6b Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Thu, 8 Oct 2026 17:21:20 -0400 Subject: [PATCH 20/23] feat(access): replace creator marker with workspace-access command Drop the "Creator" concept end to end (API is_creator field, creatorUserID DAO lookup, UI label). After granting the "user" role, keep the Add users dialog open to show the copyable `openshell workspace member add` command, and surface a per-row icon to re-open it, since user-role grants need workspace membership before the gateway is usable. Co-Authored-By: Claude Opus 4.8 --- .../openapi/openapi.gatewayAccess.yaml | 3 - .../pkg/api/openapi/api/openapi.yaml | 7 - .../docs/GatewayAccessGrantResponse.md | 23 +- .../api/openapi/docs/GatewayAccessListItem.md | 23 +- .../model_gateway_access_grant_response.go | 30 +-- .../openapi/model_gateway_access_list_item.go | 30 +-- .../api-server/plugins/gatewayAccess/dao.go | 19 -- .../plugins/gatewayAccess/integration_test.go | 13 +- .../plugins/gatewayAccess/presenter.go | 1 - .../plugins/gatewayAccess/service.go | 16 +- components/sdk-go/client/agent_runtime_api.go | 2 +- components/sdk-go/client/client.go | 2 +- .../sdk-go/client/gateway_access_api.go | 2 +- components/sdk-go/client/gateway_api.go | 2 +- .../sdk-go/client/inference_route_api.go | 2 +- components/sdk-go/client/iterator.go | 2 +- .../sdk-go/client/managed_cluster_api.go | 2 +- .../open_shell_gateway_service_account_api.go | 2 +- .../sdk-go/client/provider_binding_api.go | 2 +- components/sdk-go/client/provider_spec_api.go | 2 +- components/sdk-go/client/role_api.go | 2 +- components/sdk-go/client/role_binding_api.go | 2 +- .../sdk-go/client/sandbox_template_api.go | 2 +- components/sdk-go/client/secret_source_api.go | 2 +- components/sdk-go/client/user_api.go | 2 +- components/sdk-go/types/agent_runtime.go | 2 +- components/sdk-go/types/base.go | 2 +- components/sdk-go/types/gateway.go | 2 +- components/sdk-go/types/gateway_access.go | 4 +- components/sdk-go/types/inference_route.go | 2 +- components/sdk-go/types/list_options.go | 2 +- components/sdk-go/types/managed_cluster.go | 2 +- .../open_shell_gateway_service_account.go | 2 +- components/sdk-go/types/provider_binding.go | 2 +- components/sdk-go/types/provider_spec.go | 2 +- components/sdk-go/types/role.go | 2 +- components/sdk-go/types/role_binding.go | 2 +- components/sdk-go/types/sandbox_template.go | 2 +- components/sdk-go/types/secret_source.go | 2 +- components/sdk-go/types/user.go | 2 +- .../sdk-typescript/src/agent_runtime.ts | 2 +- .../sdk-typescript/src/agent_runtime_api.ts | 2 +- components/sdk-typescript/src/base.ts | 2 +- components/sdk-typescript/src/client.ts | 2 +- components/sdk-typescript/src/gateway.ts | 2 +- .../sdk-typescript/src/gateway_access.ts | 4 +- .../sdk-typescript/src/gateway_access_api.ts | 2 +- components/sdk-typescript/src/gateway_api.ts | 2 +- components/sdk-typescript/src/index.ts | 2 +- .../sdk-typescript/src/inference_route.ts | 2 +- .../sdk-typescript/src/inference_route_api.ts | 2 +- .../sdk-typescript/src/managed_cluster.ts | 2 +- .../sdk-typescript/src/managed_cluster_api.ts | 2 +- .../src/open_shell_gateway_service_account.ts | 2 +- .../open_shell_gateway_service_account_api.ts | 2 +- .../sdk-typescript/src/provider_binding.ts | 2 +- .../src/provider_binding_api.ts | 2 +- .../sdk-typescript/src/provider_spec.ts | 2 +- .../sdk-typescript/src/provider_spec_api.ts | 2 +- components/sdk-typescript/src/role.ts | 2 +- components/sdk-typescript/src/role_api.ts | 2 +- components/sdk-typescript/src/role_binding.ts | 2 +- .../sdk-typescript/src/role_binding_api.ts | 2 +- .../sdk-typescript/src/sandbox_template.ts | 2 +- .../src/sandbox_template_api.ts | 2 +- .../sdk-typescript/src/secret_source.ts | 2 +- .../sdk-typescript/src/secret_source_api.ts | 2 +- components/sdk-typescript/src/user.ts | 2 +- components/sdk-typescript/src/user_api.ts | 2 +- .../adapters/api/gateway-operations.test.ts | 3 - .../app/adapters/api/gateway-operations.ts | 1 - components/web-console/locales/en.json | 16 +- .../src/access/access-page.test.tsx | 77 ++++++- .../src/access/access-page.tsx | 59 ++--- .../src/access/add-users-dialog.tsx | 148 ++++++++----- .../src/access/workspace-access-command.tsx | 203 ++++++++++++++++++ .../application/gateway-operations.test.ts | 1 - .../src/application/gateway-types.ts | 1 - .../gateway-management-ui/src/messages.ts | 25 ++- .../service-account-commands.ts | 3 +- 80 files changed, 495 insertions(+), 329 deletions(-) create mode 100644 packages/gateway-management-ui/src/access/workspace-access-command.tsx diff --git a/components/api-server/openapi/openapi.gatewayAccess.yaml b/components/api-server/openapi/openapi.gatewayAccess.yaml index b59ab953e..124f3387f 100644 --- a/components/api-server/openapi/openapi.gatewayAccess.yaml +++ b/components/api-server/openapi/openapi.gatewayAccess.yaml @@ -244,7 +244,6 @@ components: - user_id - username - role - - is_creator - granted_at properties: role_binding_id: @@ -261,8 +260,6 @@ components: nullable: true role: $ref: '#/components/schemas/GatewayAccessRole' - is_creator: - type: boolean granted_at: type: string format: date-time diff --git a/components/api-server/pkg/api/openapi/api/openapi.yaml b/components/api-server/pkg/api/openapi/api/openapi.yaml index 2302a5fc5..0634057af 100644 --- a/components/api-server/pkg/api/openapi/api/openapi.yaml +++ b/components/api-server/pkg/api/openapi/api/openapi.yaml @@ -4160,7 +4160,6 @@ components: user_id: user_id name: name role_binding_id: role_binding_id - is_creator: true email: email username: username properties: @@ -4178,14 +4177,11 @@ components: type: string role: $ref: "#/components/schemas/GatewayAccessRole" - is_creator: - type: boolean granted_at: format: date-time type: string required: - granted_at - - is_creator - role - role_binding_id - user_id @@ -4200,7 +4196,6 @@ components: user_id: user_id name: name role_binding_id: role_binding_id - is_creator: true email: email username: username GatewayAccessCapabilities: @@ -4234,7 +4229,6 @@ components: user_id: user_id name: name role_binding_id: role_binding_id - is_creator: true email: email username: username - granted_at: 2000-01-23T04:56:07.000+00:00 @@ -4242,7 +4236,6 @@ components: user_id: user_id name: name role_binding_id: role_binding_id - is_creator: true email: email username: username properties: diff --git a/components/api-server/pkg/api/openapi/docs/GatewayAccessGrantResponse.md b/components/api-server/pkg/api/openapi/docs/GatewayAccessGrantResponse.md index 4afee4503..7119014ae 100644 --- a/components/api-server/pkg/api/openapi/docs/GatewayAccessGrantResponse.md +++ b/components/api-server/pkg/api/openapi/docs/GatewayAccessGrantResponse.md @@ -10,14 +10,13 @@ Name | Type | Description | Notes **Name** | Pointer to **NullableString** | | [optional] **Email** | Pointer to **NullableString** | | [optional] **Role** | [**GatewayAccessRole**](GatewayAccessRole.md) | | -**IsCreator** | **bool** | | **GrantedAt** | **time.Time** | | ## Methods ### NewGatewayAccessGrantResponse -`func NewGatewayAccessGrantResponse(roleBindingId string, userId string, username string, role GatewayAccessRole, isCreator bool, grantedAt time.Time, ) *GatewayAccessGrantResponse` +`func NewGatewayAccessGrantResponse(roleBindingId string, userId string, username string, role GatewayAccessRole, grantedAt time.Time, ) *GatewayAccessGrantResponse` NewGatewayAccessGrantResponse instantiates a new GatewayAccessGrantResponse object This constructor will assign default values to properties that have it defined, @@ -182,26 +181,6 @@ and a boolean to check if the value has been set. SetRole sets Role field to given value. -### GetIsCreator - -`func (o *GatewayAccessGrantResponse) GetIsCreator() bool` - -GetIsCreator returns the IsCreator field if non-nil, zero value otherwise. - -### GetIsCreatorOk - -`func (o *GatewayAccessGrantResponse) GetIsCreatorOk() (*bool, bool)` - -GetIsCreatorOk returns a tuple with the IsCreator field if it's non-nil, zero value otherwise -and a boolean to check if the value has been set. - -### SetIsCreator - -`func (o *GatewayAccessGrantResponse) SetIsCreator(v bool)` - -SetIsCreator sets IsCreator field to given value. - - ### GetGrantedAt `func (o *GatewayAccessGrantResponse) GetGrantedAt() time.Time` diff --git a/components/api-server/pkg/api/openapi/docs/GatewayAccessListItem.md b/components/api-server/pkg/api/openapi/docs/GatewayAccessListItem.md index 642553a2a..f445a725a 100644 --- a/components/api-server/pkg/api/openapi/docs/GatewayAccessListItem.md +++ b/components/api-server/pkg/api/openapi/docs/GatewayAccessListItem.md @@ -10,14 +10,13 @@ Name | Type | Description | Notes **Name** | Pointer to **NullableString** | | [optional] **Email** | Pointer to **NullableString** | | [optional] **Role** | [**GatewayAccessRole**](GatewayAccessRole.md) | | -**IsCreator** | **bool** | | **GrantedAt** | **time.Time** | | ## Methods ### NewGatewayAccessListItem -`func NewGatewayAccessListItem(roleBindingId string, userId string, username string, role GatewayAccessRole, isCreator bool, grantedAt time.Time, ) *GatewayAccessListItem` +`func NewGatewayAccessListItem(roleBindingId string, userId string, username string, role GatewayAccessRole, grantedAt time.Time, ) *GatewayAccessListItem` NewGatewayAccessListItem instantiates a new GatewayAccessListItem object This constructor will assign default values to properties that have it defined, @@ -182,26 +181,6 @@ and a boolean to check if the value has been set. SetRole sets Role field to given value. -### GetIsCreator - -`func (o *GatewayAccessListItem) GetIsCreator() bool` - -GetIsCreator returns the IsCreator field if non-nil, zero value otherwise. - -### GetIsCreatorOk - -`func (o *GatewayAccessListItem) GetIsCreatorOk() (*bool, bool)` - -GetIsCreatorOk returns a tuple with the IsCreator field if it's non-nil, zero value otherwise -and a boolean to check if the value has been set. - -### SetIsCreator - -`func (o *GatewayAccessListItem) SetIsCreator(v bool)` - -SetIsCreator sets IsCreator field to given value. - - ### GetGrantedAt `func (o *GatewayAccessListItem) GetGrantedAt() time.Time` diff --git a/components/api-server/pkg/api/openapi/model_gateway_access_grant_response.go b/components/api-server/pkg/api/openapi/model_gateway_access_grant_response.go index 0bdf8d9ec..944dfafd4 100644 --- a/components/api-server/pkg/api/openapi/model_gateway_access_grant_response.go +++ b/components/api-server/pkg/api/openapi/model_gateway_access_grant_response.go @@ -28,7 +28,6 @@ type GatewayAccessGrantResponse struct { Name NullableString `json:"name,omitempty"` Email NullableString `json:"email,omitempty"` Role GatewayAccessRole `json:"role"` - IsCreator bool `json:"is_creator"` GrantedAt time.Time `json:"granted_at"` } @@ -38,13 +37,12 @@ type _GatewayAccessGrantResponse GatewayAccessGrantResponse // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewGatewayAccessGrantResponse(roleBindingId string, userId string, username string, role GatewayAccessRole, isCreator bool, grantedAt time.Time) *GatewayAccessGrantResponse { +func NewGatewayAccessGrantResponse(roleBindingId string, userId string, username string, role GatewayAccessRole, grantedAt time.Time) *GatewayAccessGrantResponse { this := GatewayAccessGrantResponse{} this.RoleBindingId = roleBindingId this.UserId = userId this.Username = username this.Role = role - this.IsCreator = isCreator this.GrantedAt = grantedAt return &this } @@ -239,30 +237,6 @@ func (o *GatewayAccessGrantResponse) SetRole(v GatewayAccessRole) { o.Role = v } -// GetIsCreator returns the IsCreator field value -func (o *GatewayAccessGrantResponse) GetIsCreator() bool { - if o == nil { - var ret bool - return ret - } - - return o.IsCreator -} - -// GetIsCreatorOk returns a tuple with the IsCreator field value -// and a boolean to check if the value has been set. -func (o *GatewayAccessGrantResponse) GetIsCreatorOk() (*bool, bool) { - if o == nil { - return nil, false - } - return &o.IsCreator, true -} - -// SetIsCreator sets field value -func (o *GatewayAccessGrantResponse) SetIsCreator(v bool) { - o.IsCreator = v -} - // GetGrantedAt returns the GrantedAt field value func (o *GatewayAccessGrantResponse) GetGrantedAt() time.Time { if o == nil { @@ -307,7 +281,6 @@ func (o GatewayAccessGrantResponse) ToMap() (map[string]interface{}, error) { toSerialize["email"] = o.Email.Get() } toSerialize["role"] = o.Role - toSerialize["is_creator"] = o.IsCreator toSerialize["granted_at"] = o.GrantedAt return toSerialize, nil } @@ -321,7 +294,6 @@ func (o *GatewayAccessGrantResponse) UnmarshalJSON(data []byte) (err error) { "user_id", "username", "role", - "is_creator", "granted_at", } diff --git a/components/api-server/pkg/api/openapi/model_gateway_access_list_item.go b/components/api-server/pkg/api/openapi/model_gateway_access_list_item.go index bf0023e46..ad48413e7 100644 --- a/components/api-server/pkg/api/openapi/model_gateway_access_list_item.go +++ b/components/api-server/pkg/api/openapi/model_gateway_access_list_item.go @@ -28,7 +28,6 @@ type GatewayAccessListItem struct { Name NullableString `json:"name,omitempty"` Email NullableString `json:"email,omitempty"` Role GatewayAccessRole `json:"role"` - IsCreator bool `json:"is_creator"` GrantedAt time.Time `json:"granted_at"` } @@ -38,13 +37,12 @@ type _GatewayAccessListItem GatewayAccessListItem // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewGatewayAccessListItem(roleBindingId string, userId string, username string, role GatewayAccessRole, isCreator bool, grantedAt time.Time) *GatewayAccessListItem { +func NewGatewayAccessListItem(roleBindingId string, userId string, username string, role GatewayAccessRole, grantedAt time.Time) *GatewayAccessListItem { this := GatewayAccessListItem{} this.RoleBindingId = roleBindingId this.UserId = userId this.Username = username this.Role = role - this.IsCreator = isCreator this.GrantedAt = grantedAt return &this } @@ -239,30 +237,6 @@ func (o *GatewayAccessListItem) SetRole(v GatewayAccessRole) { o.Role = v } -// GetIsCreator returns the IsCreator field value -func (o *GatewayAccessListItem) GetIsCreator() bool { - if o == nil { - var ret bool - return ret - } - - return o.IsCreator -} - -// GetIsCreatorOk returns a tuple with the IsCreator field value -// and a boolean to check if the value has been set. -func (o *GatewayAccessListItem) GetIsCreatorOk() (*bool, bool) { - if o == nil { - return nil, false - } - return &o.IsCreator, true -} - -// SetIsCreator sets field value -func (o *GatewayAccessListItem) SetIsCreator(v bool) { - o.IsCreator = v -} - // GetGrantedAt returns the GrantedAt field value func (o *GatewayAccessListItem) GetGrantedAt() time.Time { if o == nil { @@ -307,7 +281,6 @@ func (o GatewayAccessListItem) ToMap() (map[string]interface{}, error) { toSerialize["email"] = o.Email.Get() } toSerialize["role"] = o.Role - toSerialize["is_creator"] = o.IsCreator toSerialize["granted_at"] = o.GrantedAt return toSerialize, nil } @@ -321,7 +294,6 @@ func (o *GatewayAccessListItem) UnmarshalJSON(data []byte) (err error) { "user_id", "username", "role", - "is_creator", "granted_at", } diff --git a/components/api-server/plugins/gatewayAccess/dao.go b/components/api-server/plugins/gatewayAccess/dao.go index 3b2a9d24f..a2f9b4c5c 100644 --- a/components/api-server/plugins/gatewayAccess/dao.go +++ b/components/api-server/plugins/gatewayAccess/dao.go @@ -111,25 +111,6 @@ func (d *dao) getForUser(ctx context.Context, gatewayID, userID string) (accessR return rows[0], true, nil } -// creatorUserID returns the user_id of the earliest-created gateway:owner -// binding, which is the creator (the auto-provisioned first owner). Empty when -// the gateway has no owner. -func (d *dao) creatorUserID(ctx context.Context, gatewayID string) (string, error) { - var ids []string - err := (*d.sessionFactory).New(ctx). - Table("role_bindings AS rb"). - Joins("JOIN roles r ON r.id = rb.role_id AND r.deleted_at IS NULL"). - Where("rb.gateway_id = ? AND rb.scope = ? AND rb.deleted_at IS NULL AND r.name = ?", gatewayID, roleBindings.ScopeGateway, roles.RoleGatewayOwner). - Order("rb.created_at ASC"). - Order("rb.id ASC"). - Limit(1). - Pluck("rb.user_id", &ids).Error - if err != nil || len(ids) == 0 { - return "", err - } - return ids[0], nil -} - // ownerUserIDs returns the distinct user_ids holding gateway:owner on the // gateway. Used for last-owner protection (GAM-07). func (d *dao) ownerUserIDs(ctx context.Context, gatewayID string) ([]string, error) { diff --git a/components/api-server/plugins/gatewayAccess/integration_test.go b/components/api-server/plugins/gatewayAccess/integration_test.go index 0ea549cbf..296dc1549 100644 --- a/components/api-server/plugins/gatewayAccess/integration_test.go +++ b/components/api-server/plugins/gatewayAccess/integration_test.go @@ -70,34 +70,27 @@ func accessSvc(dir gatewayAccess.DirectoryResolver) gatewayAccess.Service { return gatewayAccess.ServiceFrom(&environments.Environment().Services) } -func TestAccessList_EnrichedTiersAndCreator(t *testing.T) { +func TestAccessList_EnrichedTiers(t *testing.T) { test.RegisterIntegration(t) defer gatewayAccess.SetDirectoryResolver(nil) gw := "gw-list-enriched" - creator := seed(t, gw, "creator-user", roles.RoleGatewayOwner) + owner := seed(t, gw, "first-owner", roles.RoleGatewayOwner) seed(t, gw, "second-owner", roles.RoleGatewayOwner) seed(t, gw, "an-admin", roles.RoleGatewayAdmin) seed(t, gw, "viewer-one", roles.RoleGatewayViewer) seed(t, gw, "viewer-two", roles.RoleGatewayViewer) svc := accessSvc(realm()) - items, total, caps, aerr := svc.List(context.Background(), gw, creator, gatewayAccess.ListOptions{Page: 1, Size: 50}) + items, total, caps, aerr := svc.List(context.Background(), gw, owner, gatewayAccess.ListOptions{Page: 1, Size: 50}) Expect(aerr).To(BeNil()) Expect(total).To(Equal(int64(5))) Expect(items).To(HaveLen(5)) - creators := 0 tiers := map[string]int{} for _, it := range items { tiers[it.Role]++ - if it.IsCreator { - creators++ - Expect(it.Role).To(Equal(gatewayAccess.TierOwner)) - Expect(it.UserID).To(Equal(creator)) - } } - Expect(creators).To(Equal(1)) Expect(tiers[gatewayAccess.TierOwner]).To(Equal(2)) Expect(tiers[gatewayAccess.TierAdmin]).To(Equal(1)) Expect(tiers[gatewayAccess.TierUser]).To(Equal(2)) diff --git a/components/api-server/plugins/gatewayAccess/presenter.go b/components/api-server/plugins/gatewayAccess/presenter.go index b6f09f312..5a1ea90c9 100644 --- a/components/api-server/plugins/gatewayAccess/presenter.go +++ b/components/api-server/plugins/gatewayAccess/presenter.go @@ -9,7 +9,6 @@ type grantItemResponse struct { Name *string `json:"name,omitempty"` Email *string `json:"email,omitempty"` Role string `json:"role"` - IsCreator bool `json:"is_creator"` GrantedAt time.Time `json:"granted_at"` } diff --git a/components/api-server/plugins/gatewayAccess/service.go b/components/api-server/plugins/gatewayAccess/service.go index ef6604abd..372644408 100644 --- a/components/api-server/plugins/gatewayAccess/service.go +++ b/components/api-server/plugins/gatewayAccess/service.go @@ -78,7 +78,6 @@ type GrantItem struct { Name *string Email *string Role string - IsCreator bool GrantedAt time.Time } @@ -178,13 +177,9 @@ func (s *service) List(ctx context.Context, gatewayID, callerUserID string, opts if err != nil { return nil, 0, Capabilities{}, serverError("unable to list gateway access") } - creatorID, err := s.dao.creatorUserID(ctx, gatewayID) - if err != nil { - return nil, 0, Capabilities{}, serverError("unable to resolve gateway creator") - } items := make([]GrantItem, 0, len(rows)) for _, r := range rows { - items = append(items, grantItemFromRow(r, creatorID)) + items = append(items, grantItemFromRow(r)) } tier, aerr := s.callerTier(ctx, callerUserID, gatewayID) if aerr != nil { @@ -387,14 +382,10 @@ func (s *service) grantItem(ctx context.Context, gatewayID, userID string) (Gran if !found { return GrantItem{}, serverError("grant not found after write") } - creatorID, err := s.dao.creatorUserID(ctx, gatewayID) - if err != nil { - return GrantItem{}, serverError("unable to resolve gateway creator") - } - return grantItemFromRow(row, creatorID), nil + return grantItemFromRow(row), nil } -func grantItemFromRow(r accessRow, creatorID string) GrantItem { +func grantItemFromRow(r accessRow) GrantItem { return GrantItem{ RoleBindingID: r.RoleBindingID, UserID: r.UserID, @@ -402,7 +393,6 @@ func grantItemFromRow(r accessRow, creatorID string) GrantItem { Name: r.Name, Email: r.Email, Role: roleNameToTier(r.RoleName), - IsCreator: r.UserID == creatorID && r.RoleName == roles.RoleGatewayOwner, GrantedAt: r.GrantedAt, } } diff --git a/components/sdk-go/client/agent_runtime_api.go b/components/sdk-go/client/agent_runtime_api.go index c17294045..9d463922a 100644 --- a/components/sdk-go/client/agent_runtime_api.go +++ b/components/sdk-go/client/agent_runtime_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package client diff --git a/components/sdk-go/client/client.go b/components/sdk-go/client/client.go index 018f3d212..e4018fb34 100644 --- a/components/sdk-go/client/client.go +++ b/components/sdk-go/client/client.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package client diff --git a/components/sdk-go/client/gateway_access_api.go b/components/sdk-go/client/gateway_access_api.go index f3a69877c..8221b37b0 100644 --- a/components/sdk-go/client/gateway_access_api.go +++ b/components/sdk-go/client/gateway_access_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package client diff --git a/components/sdk-go/client/gateway_api.go b/components/sdk-go/client/gateway_api.go index 09836e6c1..75d35f8f1 100644 --- a/components/sdk-go/client/gateway_api.go +++ b/components/sdk-go/client/gateway_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package client diff --git a/components/sdk-go/client/inference_route_api.go b/components/sdk-go/client/inference_route_api.go index 93143f44e..2be83df34 100644 --- a/components/sdk-go/client/inference_route_api.go +++ b/components/sdk-go/client/inference_route_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package client diff --git a/components/sdk-go/client/iterator.go b/components/sdk-go/client/iterator.go index d78859efc..0a2ed20c3 100644 --- a/components/sdk-go/client/iterator.go +++ b/components/sdk-go/client/iterator.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package client diff --git a/components/sdk-go/client/managed_cluster_api.go b/components/sdk-go/client/managed_cluster_api.go index 864951044..2af58863c 100644 --- a/components/sdk-go/client/managed_cluster_api.go +++ b/components/sdk-go/client/managed_cluster_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package client diff --git a/components/sdk-go/client/open_shell_gateway_service_account_api.go b/components/sdk-go/client/open_shell_gateway_service_account_api.go index 4925fa0a4..f43a21bce 100644 --- a/components/sdk-go/client/open_shell_gateway_service_account_api.go +++ b/components/sdk-go/client/open_shell_gateway_service_account_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package client diff --git a/components/sdk-go/client/provider_binding_api.go b/components/sdk-go/client/provider_binding_api.go index e15c4672f..9815a406b 100644 --- a/components/sdk-go/client/provider_binding_api.go +++ b/components/sdk-go/client/provider_binding_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package client diff --git a/components/sdk-go/client/provider_spec_api.go b/components/sdk-go/client/provider_spec_api.go index e70dc36cc..da3537c23 100644 --- a/components/sdk-go/client/provider_spec_api.go +++ b/components/sdk-go/client/provider_spec_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package client diff --git a/components/sdk-go/client/role_api.go b/components/sdk-go/client/role_api.go index b17cd9f53..ee591737a 100644 --- a/components/sdk-go/client/role_api.go +++ b/components/sdk-go/client/role_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package client diff --git a/components/sdk-go/client/role_binding_api.go b/components/sdk-go/client/role_binding_api.go index 0babdd17c..aa8c5b0b3 100644 --- a/components/sdk-go/client/role_binding_api.go +++ b/components/sdk-go/client/role_binding_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package client diff --git a/components/sdk-go/client/sandbox_template_api.go b/components/sdk-go/client/sandbox_template_api.go index d040a4ea0..d40dbf73b 100644 --- a/components/sdk-go/client/sandbox_template_api.go +++ b/components/sdk-go/client/sandbox_template_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package client diff --git a/components/sdk-go/client/secret_source_api.go b/components/sdk-go/client/secret_source_api.go index fe102b02a..4d29f3388 100644 --- a/components/sdk-go/client/secret_source_api.go +++ b/components/sdk-go/client/secret_source_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package client diff --git a/components/sdk-go/client/user_api.go b/components/sdk-go/client/user_api.go index f63f4da6a..e26b308ca 100644 --- a/components/sdk-go/client/user_api.go +++ b/components/sdk-go/client/user_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package client diff --git a/components/sdk-go/types/agent_runtime.go b/components/sdk-go/types/agent_runtime.go index e0fd589ff..049d4813a 100644 --- a/components/sdk-go/types/agent_runtime.go +++ b/components/sdk-go/types/agent_runtime.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package types diff --git a/components/sdk-go/types/base.go b/components/sdk-go/types/base.go index 524448563..74d973226 100644 --- a/components/sdk-go/types/base.go +++ b/components/sdk-go/types/base.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package types diff --git a/components/sdk-go/types/gateway.go b/components/sdk-go/types/gateway.go index 57f59f6af..46b777884 100644 --- a/components/sdk-go/types/gateway.go +++ b/components/sdk-go/types/gateway.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package types diff --git a/components/sdk-go/types/gateway_access.go b/components/sdk-go/types/gateway_access.go index 52b4f0696..d29c029e6 100644 --- a/components/sdk-go/types/gateway_access.go +++ b/components/sdk-go/types/gateway_access.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package types @@ -25,7 +25,6 @@ type GatewayAccessGrantRequest struct { type GatewayAccessGrantResponse struct { Email *string `json:"email,omitempty"` GrantedAt time.Time `json:"granted_at"` - IsCreator bool `json:"is_creator"` Name *string `json:"name,omitempty"` Role GatewayAccessRole `json:"role"` RoleBindingID string `json:"role_binding_id"` @@ -44,7 +43,6 @@ type GatewayAccessList struct { type GatewayAccessListItem struct { Email *string `json:"email,omitempty"` GrantedAt time.Time `json:"granted_at"` - IsCreator bool `json:"is_creator"` Name *string `json:"name,omitempty"` Role GatewayAccessRole `json:"role"` RoleBindingID string `json:"role_binding_id"` diff --git a/components/sdk-go/types/inference_route.go b/components/sdk-go/types/inference_route.go index c0cf6bcf1..d6ff21200 100644 --- a/components/sdk-go/types/inference_route.go +++ b/components/sdk-go/types/inference_route.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package types diff --git a/components/sdk-go/types/list_options.go b/components/sdk-go/types/list_options.go index 5969889b5..5aa7719f1 100644 --- a/components/sdk-go/types/list_options.go +++ b/components/sdk-go/types/list_options.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package types diff --git a/components/sdk-go/types/managed_cluster.go b/components/sdk-go/types/managed_cluster.go index 49de51cd4..f5cc232e3 100644 --- a/components/sdk-go/types/managed_cluster.go +++ b/components/sdk-go/types/managed_cluster.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package types diff --git a/components/sdk-go/types/open_shell_gateway_service_account.go b/components/sdk-go/types/open_shell_gateway_service_account.go index 684aec41d..0c0e0932e 100644 --- a/components/sdk-go/types/open_shell_gateway_service_account.go +++ b/components/sdk-go/types/open_shell_gateway_service_account.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package types diff --git a/components/sdk-go/types/provider_binding.go b/components/sdk-go/types/provider_binding.go index fbe7d40c3..32bbea9af 100644 --- a/components/sdk-go/types/provider_binding.go +++ b/components/sdk-go/types/provider_binding.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package types diff --git a/components/sdk-go/types/provider_spec.go b/components/sdk-go/types/provider_spec.go index 9bad108fc..b505bdb38 100644 --- a/components/sdk-go/types/provider_spec.go +++ b/components/sdk-go/types/provider_spec.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package types diff --git a/components/sdk-go/types/role.go b/components/sdk-go/types/role.go index 56b3a63b6..aab5fa4a4 100644 --- a/components/sdk-go/types/role.go +++ b/components/sdk-go/types/role.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package types diff --git a/components/sdk-go/types/role_binding.go b/components/sdk-go/types/role_binding.go index a552df839..438facd91 100644 --- a/components/sdk-go/types/role_binding.go +++ b/components/sdk-go/types/role_binding.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package types diff --git a/components/sdk-go/types/sandbox_template.go b/components/sdk-go/types/sandbox_template.go index c3df6c890..8bb8c16fc 100644 --- a/components/sdk-go/types/sandbox_template.go +++ b/components/sdk-go/types/sandbox_template.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package types diff --git a/components/sdk-go/types/secret_source.go b/components/sdk-go/types/secret_source.go index ffd566738..f4bafb345 100644 --- a/components/sdk-go/types/secret_source.go +++ b/components/sdk-go/types/secret_source.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package types diff --git a/components/sdk-go/types/user.go b/components/sdk-go/types/user.go index e6943d71b..c7f5d6617 100644 --- a/components/sdk-go/types/user.go +++ b/components/sdk-go/types/user.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 package types diff --git a/components/sdk-typescript/src/agent_runtime.ts b/components/sdk-typescript/src/agent_runtime.ts index 9270a1ab5..fee6d22e8 100644 --- a/components/sdk-typescript/src/agent_runtime.ts +++ b/components/sdk-typescript/src/agent_runtime.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/agent_runtime_api.ts b/components/sdk-typescript/src/agent_runtime_api.ts index ad337a295..59a44af1e 100644 --- a/components/sdk-typescript/src/agent_runtime_api.ts +++ b/components/sdk-typescript/src/agent_runtime_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/base.ts b/components/sdk-typescript/src/base.ts index 7d5bb930d..a59dfdc60 100644 --- a/components/sdk-typescript/src/base.ts +++ b/components/sdk-typescript/src/base.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 export type ObjectReference = { id: string; diff --git a/components/sdk-typescript/src/client.ts b/components/sdk-typescript/src/client.ts index 51697e1d1..6bdf5a785 100644 --- a/components/sdk-typescript/src/client.ts +++ b/components/sdk-typescript/src/client.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { SDKClientConfig } from './base.js'; import { AgentRuntimeAPI } from './agent_runtime_api.js'; diff --git a/components/sdk-typescript/src/gateway.ts b/components/sdk-typescript/src/gateway.ts index 3c6dcd1df..a4de6a28d 100644 --- a/components/sdk-typescript/src/gateway.ts +++ b/components/sdk-typescript/src/gateway.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_access.ts b/components/sdk-typescript/src/gateway_access.ts index 627bdf386..88f56d675 100644 --- a/components/sdk-typescript/src/gateway_access.ts +++ b/components/sdk-typescript/src/gateway_access.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 export type GatewayAccessCapabilities = { @@ -28,7 +28,6 @@ export type GatewayAccessGrantRequest = { export type GatewayAccessGrantResponse = { email?: string | null; granted_at: string; - is_creator: boolean; name?: string | null; role: GatewayAccessRole; role_binding_id: string; @@ -51,7 +50,6 @@ export type GatewayAccessList = { export type GatewayAccessListItem = { email?: string | null; granted_at: string; - is_creator: boolean; name?: string | null; role: GatewayAccessRole; role_binding_id: string; diff --git a/components/sdk-typescript/src/gateway_access_api.ts b/components/sdk-typescript/src/gateway_access_api.ts index 45176fe00..0adc6624d 100644 --- a/components/sdk-typescript/src/gateway_access_api.ts +++ b/components/sdk-typescript/src/gateway_access_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { SDKClientConfig, RequestOptions } from './base.js'; import { sdkFetch } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_api.ts b/components/sdk-typescript/src/gateway_api.ts index 588df65c6..292567c74 100644 --- a/components/sdk-typescript/src/gateway_api.ts +++ b/components/sdk-typescript/src/gateway_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/index.ts b/components/sdk-typescript/src/index.ts index 3f41528de..018236002 100644 --- a/components/sdk-typescript/src/index.ts +++ b/components/sdk-typescript/src/index.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 export { SDKClient } from './client.js'; export type { SDKClientConfig, ListOptions, RequestOptions, ObjectReference, ListMeta, APIError } from './base.js'; diff --git a/components/sdk-typescript/src/inference_route.ts b/components/sdk-typescript/src/inference_route.ts index 1fc3751e0..144b303b2 100644 --- a/components/sdk-typescript/src/inference_route.ts +++ b/components/sdk-typescript/src/inference_route.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/inference_route_api.ts b/components/sdk-typescript/src/inference_route_api.ts index a3af34882..c6dd2327c 100644 --- a/components/sdk-typescript/src/inference_route_api.ts +++ b/components/sdk-typescript/src/inference_route_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/managed_cluster.ts b/components/sdk-typescript/src/managed_cluster.ts index 3c21f3e76..ce80ad504 100644 --- a/components/sdk-typescript/src/managed_cluster.ts +++ b/components/sdk-typescript/src/managed_cluster.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/managed_cluster_api.ts b/components/sdk-typescript/src/managed_cluster_api.ts index 89bc27fff..f3ad33860 100644 --- a/components/sdk-typescript/src/managed_cluster_api.ts +++ b/components/sdk-typescript/src/managed_cluster_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/open_shell_gateway_service_account.ts b/components/sdk-typescript/src/open_shell_gateway_service_account.ts index 4df16679c..7af717926 100644 --- a/components/sdk-typescript/src/open_shell_gateway_service_account.ts +++ b/components/sdk-typescript/src/open_shell_gateway_service_account.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 export type OpenShellGatewayServiceAccountCapabilities = { diff --git a/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts b/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts index 4c78d7e6a..c0b653ad3 100644 --- a/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts +++ b/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { SDKClientConfig, RequestOptions } from './base.js'; import { sdkFetch } from './base.js'; diff --git a/components/sdk-typescript/src/provider_binding.ts b/components/sdk-typescript/src/provider_binding.ts index 7da64d58a..18d295662 100644 --- a/components/sdk-typescript/src/provider_binding.ts +++ b/components/sdk-typescript/src/provider_binding.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/provider_binding_api.ts b/components/sdk-typescript/src/provider_binding_api.ts index 3ab4a4999..22baa3b75 100644 --- a/components/sdk-typescript/src/provider_binding_api.ts +++ b/components/sdk-typescript/src/provider_binding_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/provider_spec.ts b/components/sdk-typescript/src/provider_spec.ts index cf2bb8315..f7ab7dcff 100644 --- a/components/sdk-typescript/src/provider_spec.ts +++ b/components/sdk-typescript/src/provider_spec.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/provider_spec_api.ts b/components/sdk-typescript/src/provider_spec_api.ts index af6a6619b..950d8373f 100644 --- a/components/sdk-typescript/src/provider_spec_api.ts +++ b/components/sdk-typescript/src/provider_spec_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/role.ts b/components/sdk-typescript/src/role.ts index 6343bc7d8..02f8c3e40 100644 --- a/components/sdk-typescript/src/role.ts +++ b/components/sdk-typescript/src/role.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/role_api.ts b/components/sdk-typescript/src/role_api.ts index b420e453f..5d8f0629e 100644 --- a/components/sdk-typescript/src/role_api.ts +++ b/components/sdk-typescript/src/role_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/role_binding.ts b/components/sdk-typescript/src/role_binding.ts index 5b5939979..4eeb7c819 100644 --- a/components/sdk-typescript/src/role_binding.ts +++ b/components/sdk-typescript/src/role_binding.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/role_binding_api.ts b/components/sdk-typescript/src/role_binding_api.ts index 3db5fd77f..dc5eda7f4 100644 --- a/components/sdk-typescript/src/role_binding_api.ts +++ b/components/sdk-typescript/src/role_binding_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/sandbox_template.ts b/components/sdk-typescript/src/sandbox_template.ts index bfc17ebdd..122c97279 100644 --- a/components/sdk-typescript/src/sandbox_template.ts +++ b/components/sdk-typescript/src/sandbox_template.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/sandbox_template_api.ts b/components/sdk-typescript/src/sandbox_template_api.ts index e1f05f648..3d6d39004 100644 --- a/components/sdk-typescript/src/sandbox_template_api.ts +++ b/components/sdk-typescript/src/sandbox_template_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/secret_source.ts b/components/sdk-typescript/src/secret_source.ts index cdf430c4b..05a623020 100644 --- a/components/sdk-typescript/src/secret_source.ts +++ b/components/sdk-typescript/src/secret_source.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/secret_source_api.ts b/components/sdk-typescript/src/secret_source_api.ts index 2fd1c32d5..ba9ff80d5 100644 --- a/components/sdk-typescript/src/secret_source_api.ts +++ b/components/sdk-typescript/src/secret_source_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/user.ts b/components/sdk-typescript/src/user.ts index 638c22ecd..3e5c8cd80 100644 --- a/components/sdk-typescript/src/user.ts +++ b/components/sdk-typescript/src/user.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/user_api.ts b/components/sdk-typescript/src/user_api.ts index d51641a5a..de6291408 100644 --- a/components/sdk-typescript/src/user_api.ts +++ b/components/sdk-typescript/src/user_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 362a6e78860ff6da7343d12732c5e76e48d2bf479e176535f39bf8bb8fc4d389 +// Spec SHA256: f9a7f9e6f68c4c50cda14c134b406761756e203747912d4e457dee64d0a3f082 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/web-console/app/adapters/api/gateway-operations.test.ts b/components/web-console/app/adapters/api/gateway-operations.test.ts index fedfb0180..4a12a494c 100644 --- a/components/web-console/app/adapters/api/gateway-operations.test.ts +++ b/components/web-console/app/adapters/api/gateway-operations.test.ts @@ -796,7 +796,6 @@ describe("gateway API operations adapter", () => { { email: null, granted_at: "2026-10-01T00:00:00Z", - is_creator: true, name: "Ann Owner", role: "owner", role_binding_id: "rb-1", @@ -826,7 +825,6 @@ describe("gateway API operations adapter", () => { canManageOwners: true, }); expect(page.items[0]).toMatchObject({ - isCreator: true, name: "Ann Owner", role: "owner", roleBindingId: "rb-1", @@ -840,7 +838,6 @@ describe("gateway API operations adapter", () => { gatewayAccessApi.create.mockResolvedValue({ email: "dana@example.com", granted_at: "2026-10-02T00:00:00Z", - is_creator: false, name: "Dana", role: "user", role_binding_id: "rb-2", diff --git a/components/web-console/app/adapters/api/gateway-operations.ts b/components/web-console/app/adapters/api/gateway-operations.ts index 8596e3069..7e50ea7e9 100644 --- a/components/web-console/app/adapters/api/gateway-operations.ts +++ b/components/web-console/app/adapters/api/gateway-operations.ts @@ -223,7 +223,6 @@ function toAccessGrantRecord( return { ...(email ? { email } : {}), grantedAt: item.granted_at, - isCreator: item.is_creator, ...(name ? { name } : {}), role: item.role, roleBindingId: item.role_binding_id, diff --git a/components/web-console/locales/en.json b/components/web-console/locales/en.json index 36f68e317..20d1143ac 100644 --- a/components/web-console/locales/en.json +++ b/components/web-console/locales/en.json @@ -763,10 +763,6 @@ "defaultMessage": "Name", "description": "Access table column for the user's display name." }, - "app.gateway.access.creatorMarker": { - "defaultMessage": "Creator", - "description": "Marker shown on the row of the gateway creator." - }, "app.gateway.access.description": { "defaultMessage": "Manage who can access this gateway and at what role.", "description": "Description under the Manage access heading." @@ -923,6 +919,18 @@ "defaultMessage": "You have read-only access to this list.", "description": "Notice shown to viewers who cannot manage access." }, + "app.gateway.access.workspaceGrant.intro": { + "defaultMessage": "This user cannot use the gateway until they are added to a workspace. A user with admin access on this gateway must run the below commands (change the workspace name if needed):", + "description": "Instructions for adding a newly granted user to an OpenShell workspace." + }, + "app.gateway.access.workspaceGrant.lookupPending": { + "defaultMessage": "Looking up the user's subject ID...", + "description": "Loading text shown while resolving a user's subject for the workspace command." + }, + "app.gateway.access.workspaceGrant.subjectUnavailable": { + "defaultMessage": "Could not determine this user's subject ID. The identity directory may be unavailable; try again.", + "description": "Error shown when a user's subject cannot be resolved for the workspace command." + }, "app.gateway.cliConnection": { "defaultMessage": "CLI connection", "description": "Label for a gateway's OpenShell CLI connection command." diff --git a/packages/gateway-management-ui/src/access/access-page.test.tsx b/packages/gateway-management-ui/src/access/access-page.test.tsx index 8ff71a495..d3e16cede 100644 --- a/packages/gateway-management-ui/src/access/access-page.test.tsx +++ b/packages/gateway-management-ui/src/access/access-page.test.tsx @@ -15,7 +15,6 @@ import { AccessPage } from "./access-page"; const creator: GatewayAccessGrantRecord = { grantedAt: "2026-08-21T12:00:00Z", - isCreator: true, name: "Olivia Owner", role: "owner", roleBindingId: "rb-owner", @@ -24,7 +23,6 @@ const creator: GatewayAccessGrantRecord = { }; const secondOwner: GatewayAccessGrantRecord = { grantedAt: "2026-08-21T12:00:00Z", - isCreator: false, name: "Owen Two", role: "owner", roleBindingId: "rb-owner-2", @@ -33,7 +31,6 @@ const secondOwner: GatewayAccessGrantRecord = { }; const admin: GatewayAccessGrantRecord = { grantedAt: "2026-08-21T12:00:00Z", - isCreator: false, name: "Amy Admin", role: "admin", roleBindingId: "rb-admin", @@ -42,7 +39,6 @@ const admin: GatewayAccessGrantRecord = { }; const user: GatewayAccessGrantRecord = { grantedAt: "2026-08-21T12:00:00Z", - isCreator: false, name: "Uma User", role: "user", roleBindingId: "rb-user", @@ -141,14 +137,12 @@ describe("AccessPage", () => { mocks.revoke.mockResolvedValue(undefined); }); - it("renders names, user IDs, roles, and marks the creator", async () => { + it("renders names, user IDs, and roles", async () => { renderPage(); expect(await screen.findByText("Olivia Owner")).toBeTruthy(); expect(screen.getByText("owner1")).toBeTruthy(); expect(screen.getByText("Amy Admin")).toBeTruthy(); expect(screen.getByText("Uma User")).toBeTruthy(); - // Exactly one Creator marker. - expect(screen.getAllByText("Creator")).toHaveLength(1); }); it("disables the sole owner's controls", async () => { @@ -334,6 +328,75 @@ describe("AccessPage", () => { }); }); + it("shows the workspace-access command after granting the user role", async () => { + const user1 = userEvent.setup(); + mocks.search.mockResolvedValue([ + { name: "Dana Scully", subject: "sub-dana", username: "dana" }, + ]); + renderPage(); + await screen.findByText("Olivia Owner"); + + await user1.click(screen.getByRole("button", { name: "Add users" })); + const dialog = screen.getByRole("dialog", { name: "Add users" }); + await user1.click( + within(dialog).getByRole("combobox", { + name: "Search the identity directory", + }), + ); + await user1.click(await screen.findByText(/Dana Scully/u)); + await user1.click(within(dialog).getByRole("radio", { name: /User/u })); + await user1.click( + within(dialog).getByRole("button", { name: "Grant access" }), + ); + + await waitFor(() => { + expect(mocks.grant).toHaveBeenCalledWith("gateway-1", { + role: "user", + subject: "sub-dana", + username: "dana", + }); + }); + // The dialog stays open and shows the copyable workspace membership command. + expect( + await screen.findByText(/openshell workspace member add/u), + ).toBeTruthy(); + expect(screen.getByText(/--subject sub-dana/u)).toBeTruthy(); + expect(screen.getByText(/WORKSPACE_NAME='default'/u)).toBeTruthy(); + }); + + it("re-opens the workspace-access command from a row icon", async () => { + const user1 = userEvent.setup(); + mocks.list.mockResolvedValue( + accessPage([creator, admin, user], ownerCapabilities), + ); + mocks.search.mockResolvedValue([ + { name: "Uma User", subject: "sub-uma", username: "user1" }, + ]); + renderPage(); + await screen.findByText("Uma User"); + + // The copy icon only appears on rows with the user role (owner/admin rows + // are management-plane and do not need workspace membership). + expect( + screen.getAllByRole("button", { + name: "Copy workspace access command", + }), + ).toHaveLength(1); + + await user1.click( + screen.getByRole("button", { name: "Copy workspace access command" }), + ); + expect( + await screen.findByText(/openshell workspace member add/u), + ).toBeTruthy(); + expect(screen.getByText(/--subject sub-uma/u)).toBeTruthy(); + expect(mocks.search).toHaveBeenCalledWith( + "gateway-1", + "user1", + expect.any(AbortSignal), + ); + }); + it("hides the Owner option from a non-owner admin", async () => { const user1 = userEvent.setup(); mocks.list.mockResolvedValue(accessPage([admin], adminCapabilities)); diff --git a/packages/gateway-management-ui/src/access/access-page.tsx b/packages/gateway-management-ui/src/access/access-page.tsx index e91f64870..9eb908e39 100644 --- a/packages/gateway-management-ui/src/access/access-page.tsx +++ b/packages/gateway-management-ui/src/access/access-page.tsx @@ -4,14 +4,11 @@ import { Content, Flex, FlexItem, - Label, MenuToggle, Select, SelectList, SelectOption, Spinner, - Split, - SplitItem, Stack, StackItem, Title, @@ -46,6 +43,7 @@ import { import { AccessRemoveAction, AccessRoleControl } from "./access-row-actions"; import { roleLabel, rowDisabledReason } from "./access-role"; import { AddUsersDialog } from "./add-users-dialog"; +import { GrantWorkspaceAccessAction } from "./workspace-access-command"; function RoleFilter({ onChange, @@ -191,20 +189,7 @@ export function AccessPage({ { id: "name", label: intl.formatMessage(messages.accessUserName), - render: (grant) => ( - - - {grant.name?.trim() ? grant.name : grant.username} - - {grant.isCreator ? ( - - - - ) : null} - - ), + render: (grant) => (grant.name?.trim() ? grant.name : grant.username), sortable: false, width: 35, }, @@ -220,18 +205,34 @@ export function AccessPage({ label: intl.formatMessage(messages.accessRoleColumn), render: (grant) => canManage && capabilities ? ( - + + + + + {grant.role === "user" ? ( + + + + ) : null} + ) : ( roleLabel(intl.formatMessage, grant.role) ), diff --git a/packages/gateway-management-ui/src/access/add-users-dialog.tsx b/packages/gateway-management-ui/src/access/add-users-dialog.tsx index 4043c36df..e9a6fd800 100644 --- a/packages/gateway-management-ui/src/access/add-users-dialog.tsx +++ b/packages/gateway-management-ui/src/access/add-users-dialog.tsx @@ -45,6 +45,7 @@ import { roleDescription, roleLabel, } from "./access-role"; +import { WorkspaceAccessCommand } from "./workspace-access-command"; const loadingValue = Symbol(); const noResultsValue = Symbol(); @@ -239,14 +240,23 @@ export function AddUsersDialog({ }); }, onSuccess: async () => { - onClose(); await queryClient.invalidateQueries({ queryKey: accessListQueryRoot(gatewayId), }); + // A user granted the "user" role still needs to be added to a workspace + // before they can use the gateway, so keep the dialog open to show the + // admin command. Other roles are management-plane only, so close as usual. + if (role !== "user") { + onClose(); + } }, retry: false, }); + // After a successful "user" grant, swap the form for the workspace-access + // command (the directory selection still holds the resolved subject). + const showWorkspaceStep = mutation.isSuccess && role === "user"; + const close = () => { mutation.reset(); onClose(); @@ -257,74 +267,94 @@ export function AddUsersDialog({ aria-labelledby={titleId} isOpen={isOpen} onClose={mutation.isPending ? undefined : close} - variant={ModalVariant.small} + variant={showWorkspaceStep ? ModalVariant.medium : ModalVariant.small} > -
- - - - {selected ? ( + {showWorkspaceStep ? ( + + ) : ( + - - {roles.map((candidateRole) => ( - - { - setRole(candidateRole); - }} - /> - - ))} - + - ) : null} - {mutation.isError ? ( - - ) : null} - + {selected ? ( + + + {roles.map((candidateRole) => ( + + { + setRole(candidateRole); + }} + /> + + ))} + + + ) : null} + {mutation.isError ? ( + + ) : null} + + )}
- - + {showWorkspaceStep ? ( + + ) : ( + <> + + + + )} ); diff --git a/packages/gateway-management-ui/src/access/workspace-access-command.tsx b/packages/gateway-management-ui/src/access/workspace-access-command.tsx new file mode 100644 index 000000000..7ade6bb51 --- /dev/null +++ b/packages/gateway-management-ui/src/access/workspace-access-command.tsx @@ -0,0 +1,203 @@ +import { + Alert, + Button, + ClipboardCopyButton, + CodeBlock, + CodeBlockCode, + Content, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalVariant, + Spinner, + Stack, + StackItem, + Tooltip, +} from "@patternfly/react-core"; +import { CopyIcon } from "@patternfly/react-icons"; +import { useQuery } from "@tanstack/react-query"; +import { useId, useState } from "react"; +import { useIntl } from "react-intl"; + +import type { GatewayAccessGrantRecord } from "../application/gateway-types"; +import { useGatewayUi } from "../gateway-ui-provider"; +import { messages } from "../messages"; +import { buildWorkspaceMembershipCommand } from "../service-accounts/service-account-commands"; +import { accessDirectoryQueryKey } from "./access-data"; + +// The default OpenShell workspace new users are added to (GAM). The admin can +// change WORKSPACE_NAME in the copied command before running it. +const defaultWorkspace = "default"; + +function CommandBlock({ command }: { command: string }) { + const intl = useIntl(); + const id = useId(); + const [copied, setCopied] = useState(false); + // Overlay the copy button on the code's top-right corner instead of using + // CodeBlock's `actions`, which renders a separate (near-empty) header row. + return ( +
+ { + void navigator.clipboard.writeText(command); + setCopied(true); + }} + onTooltipHidden={() => { + setCopied(false); + }} + style={{ + insetBlockStart: "var(--pf-t--global--spacer--xs)", + insetInlineEnd: "var(--pf-t--global--spacer--xs)", + position: "absolute", + zIndex: 1, + }} + variant="plain" + > + {intl.formatMessage(copied ? messages.copied : messages.copy)} + + + {command} + +
+ ); +} + +// WorkspaceAccessCommand renders the gateway-admin instructions plus the copyable +// `openshell workspace member add` command for a resolved subject. subject is +// undefined while a lookup is pending/failed; the caller passes isPending so this +// can show a spinner rather than the "unavailable" error during the lookup. +export function WorkspaceAccessCommand({ + isPending = false, + subject, +}: { + isPending?: boolean; + subject: string | undefined; +}) { + const intl = useIntl(); + const command = subject + ? buildWorkspaceMembershipCommand(subject, defaultWorkspace) + : undefined; + + if (isPending) { + return ( + + ); + } + if (!command) { + return ( + + ); + } + return ( + + + + {intl.formatMessage(messages.accessWorkspaceGrantIntro)} + + + + + + + ); +} + +// resolveSubject looks a username up in the gateway directory and returns the +// matching user's subject. The grant row carries no subject (it is a Keycloak +// projection, not stored), so it is looked up on demand (GAM). +function useSubjectLookup( + gatewayId: string, + username: string, + enabled: boolean, +) { + const { gateways } = useGatewayUi(); + return useQuery({ + enabled, + queryFn: async ({ signal }) => { + const candidates = await gateways.searchGatewayDirectory( + gatewayId, + username, + signal, + ); + const match = candidates.find( + (candidate) => + candidate.username.toLowerCase() === username.toLowerCase(), + ); + return match?.subject ?? ""; + }, + queryKey: [...accessDirectoryQueryKey(gatewayId, username), "subject"], + staleTime: 60_000, + }); +} + +// GrantWorkspaceAccessAction is the per-row icon that re-opens the "Grant +// workspace access" command for an existing user, looking the subject up on open. +export function GrantWorkspaceAccessAction({ + gatewayId, + grant, +}: { + gatewayId: string; + grant: GatewayAccessGrantRecord; +}) { + const intl = useIntl(); + const titleId = useId(); + const [isOpen, setIsOpen] = useState(false); + const lookup = useSubjectLookup(gatewayId, grant.username, isOpen); + const label = intl.formatMessage(messages.copyWorkspaceGrantCommand); + + return ( + <> + + + + + ) : null} + + ); +} diff --git a/packages/gateway-management-ui/src/application/gateway-operations.test.ts b/packages/gateway-management-ui/src/application/gateway-operations.test.ts index 09424e051..e6cb5f89d 100644 --- a/packages/gateway-management-ui/src/application/gateway-operations.test.ts +++ b/packages/gateway-management-ui/src/application/gateway-operations.test.ts @@ -35,7 +35,6 @@ function setup() { const renameGateway = vi.fn().mockResolvedValue(gateway); const grant = { grantedAt: "2026-08-06T18:00:00.000Z", - isCreator: false, role: "user" as const, roleBindingId: "rb-1", userId: "user-1", diff --git a/packages/gateway-management-ui/src/application/gateway-types.ts b/packages/gateway-management-ui/src/application/gateway-types.ts index cba2da12e..347e14861 100644 --- a/packages/gateway-management-ui/src/application/gateway-types.ts +++ b/packages/gateway-management-ui/src/application/gateway-types.ts @@ -199,7 +199,6 @@ export type GatewayAccessRole = "admin" | "owner" | "user"; export interface GatewayAccessGrantRecord { email?: string; grantedAt: string; - isCreator: boolean; name?: string; role: GatewayAccessRole; roleBindingId: string; diff --git a/packages/gateway-management-ui/src/messages.ts b/packages/gateway-management-ui/src/messages.ts index 79456b2ea..77cd8cc9c 100644 --- a/packages/gateway-management-ui/src/messages.ts +++ b/packages/gateway-management-ui/src/messages.ts @@ -1151,11 +1151,6 @@ export const messages = defineMessages({ defaultMessage: "Read-only access to the gateway.", description: "User role description in the role picker.", }, - accessCreatorMarker: { - id: "app.gateway.access.creatorMarker", - defaultMessage: "Creator", - description: "Marker shown on the row of the gateway creator.", - }, accessFindPeople: { id: "app.gateway.access.findPeople", defaultMessage: "Find people...", @@ -1338,5 +1333,25 @@ export const messages = defineMessages({ defaultMessage: "You have read-only access to this list.", description: "Notice shown to viewers who cannot manage access.", }, + accessWorkspaceGrantIntro: { + id: "app.gateway.access.workspaceGrant.intro", + defaultMessage: + "This user cannot use the gateway until they are added to a workspace. A user with admin access on this gateway must run the below commands (change the workspace name if needed):", + description: + "Instructions for adding a newly granted user to an OpenShell workspace.", + }, + accessWorkspaceLookupPending: { + id: "app.gateway.access.workspaceGrant.lookupPending", + defaultMessage: "Looking up the user's subject ID...", + description: + "Loading text shown while resolving a user's subject for the workspace command.", + }, + accessWorkspaceSubjectUnavailable: { + id: "app.gateway.access.workspaceGrant.subjectUnavailable", + defaultMessage: + "Could not determine this user's subject ID. The identity directory may be unavailable; try again.", + description: + "Error shown when a user's subject cannot be resolved for the workspace command.", + }, /* eslint-enable sort-keys */ }); diff --git a/packages/gateway-management-ui/src/service-accounts/service-account-commands.ts b/packages/gateway-management-ui/src/service-accounts/service-account-commands.ts index 3166d0419..181c791e5 100644 --- a/packages/gateway-management-ui/src/service-accounts/service-account-commands.ts +++ b/packages/gateway-management-ui/src/service-accounts/service-account-commands.ts @@ -35,12 +35,13 @@ export function serviceAccountGatewayAlias( export function buildWorkspaceMembershipCommand( subject: string, + workspace = "replace-with-workspace-name", ): string | undefined { if (!subject.trim()) { return undefined; } return [ - "WORKSPACE_NAME='replace-with-workspace-name'", + `WORKSPACE_NAME='${workspace}'`, "", "openshell workspace member add \\", ' --workspace "$WORKSPACE_NAME" \\', From f5948bb4f1bd8d561f181b4c84db44bff2d8d4ef Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Thu, 8 Oct 2026 20:10:56 -0400 Subject: [PATCH 21/23] docs(gateway-access): drop is_creator from specs to match implementation Commit 0908480 removed the "Creator" concept end to end (is_creator API field, creatorUserID DAO lookup, OpenAPI schema, regenerated SDKs, UI label) but left the specs mandating it, so the is_creator SHALL statements were unsatisfiable against the code and a later /reconcile would read them as a gap and try to re-add the field. Update the desired-state specs to match: - platform: drop the is_creator data-model row, the is_creator assertions in the "Owner lists access" scenario, and the is_creator display/audit prose (L38/L310). The RBAC "creator is the auto-provisioned first owner" concept stays; it is simply no longer surfaced as a distinct field. - web-console: drop the "Creator" marker requirement, its scenario assertion, and the creator-marker references in the i18n, test, and fixtures lists. Addresses PR #447 review finding (is_creator spec/code drift). Co-Authored-By: Claude Opus 4.8 --- specs/platform/gateway-access-management.spec.md | 11 +++++------ specs/web-console/gateway-access-management.spec.md | 11 +++++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/specs/platform/gateway-access-management.spec.md b/specs/platform/gateway-access-management.spec.md index 2a02690e4..bdccb8b90 100644 --- a/specs/platform/gateway-access-management.spec.md +++ b/specs/platform/gateway-access-management.spec.md @@ -35,7 +35,7 @@ The three per-gateway roles form a strict hierarchy, **Owner > Admin > Viewer**: Separately, `platform:admin` (a global, Keycloak-sourced role, not a per-gateway role) has **owner-equivalent access-management authority on every gateway**: it may list, grant, change, and revoke any tier (including `gateway:owner`) and search the directory, without holding any per-gateway binding (GAM-08). It is purely a management-plane authority: it confers **no gateway login** (no Keycloak client roles) unless the `platform:admin` is separately granted a per-gateway binding, so a platform administrator managing a gateway's access does not thereby gain `openshell` CLI access to it. -The creator (the auto-provisioned first owner, `security/rbac-enforcement.spec.md`) is tracked for display and audit (`is_creator`, "Created by") but holds no capability beyond any other owner; ownership is shared and transferable among owners. +The creator (the auto-provisioned first owner, `security/rbac-enforcement.spec.md`) holds no capability beyond any other owner; ownership is shared and transferable among owners, and the first owner is not distinguished from the others in the access list. ## Non-Goals @@ -149,7 +149,6 @@ Each grant item SHALL expose: | `name` | string | `User.name` display name, when present; the console "Name" column | | `email` | string | Optional | | `role` | string | `owner`, `admin`, or `user` (the console tier) mapping to `gateway:owner`, `gateway:admin`, `gateway:viewer` | -| `is_creator` | boolean | `true` for the owner who created the gateway (informational only; see GAM-07) | | `granted_at` | date-time | Binding creation time | The response SHALL be assembled server-side with the user fields already joined; it SHALL NOT require the caller to perform one lookup per row. The endpoint SHALL support `search` (case-insensitive substring over `username` and `name`), a `role` filter (`owner`, `admin`, or `user`), pagination, and ordering, following the shared list contract. Search terms SHALL be treated as literals (wildcard/escape semantics handled server-side). @@ -158,10 +157,10 @@ Any caller with a binding on the gateway (owner, admin, or viewer) or `platform: #### Scenario: Owner lists access -- GIVEN gw-1 has a creator (owner), one additional `gateway:owner`, one `gateway:admin`, and two `gateway:viewer` grants +- GIVEN gw-1 has two `gateway:owner`, one `gateway:admin`, and two `gateway:viewer` grants - WHEN an owner calls `GET /api/hypershell/v1/gateways/gw-1/access` -- THEN the response SHALL contain five items with `username`, `name`, `role`, and `is_creator` populated -- AND exactly one item SHALL have `is_creator: true` and `role: owner` +- THEN the response SHALL contain five items with `username`, `name`, and `role` populated +- AND exactly two items SHALL have `role: owner` #### Scenario: Filter by role and search by name @@ -307,7 +306,7 @@ Each `409` response SHALL carry a detailed, human-readable error message explain These rejections SHALL apply regardless of the caller, including an owner acting on themselves. When more than one owner exists, any owner (including the creator) MAY be demoted or revoked by an owner. Ownership is therefore shared and transferable: the creator is not uniquely protected once another owner exists; it is the **last owner** that cannot be removed. -The creator (the auto-provisioned first owner) is retained as `is_creator` for display and audit only and SHALL NOT, by itself, confer immutability. +The creator (the auto-provisioned first owner) is not distinguished from any other owner and SHALL NOT, by itself, confer immutability; only the **last owner** constraint protects a row. #### Scenario: Sole owner cannot demote themselves diff --git a/specs/web-console/gateway-access-management.spec.md b/specs/web-console/gateway-access-management.spec.md index 38796520b..6196f7c10 100644 --- a/specs/web-console/gateway-access-management.spec.md +++ b/specs/web-console/gateway-access-management.spec.md @@ -44,16 +44,15 @@ Each row SHALL show: | Role | `role` (`owner`, `admin`, or `user`), rendered as an inline control per GAM-UI-05 | | (row action) | Remove access per GAM-UI-06 | -The creator's row (`is_creator: true`) SHALL be indicated (for example a "Creator" marker) for context; this is informational and does not by itself lock the row. A row's role control and remove action SHALL be disabled with an accessible explanation when the server would reject the action (GAM-UI-08): the user is the last remaining owner, or the current caller is not an owner and the row is an owner. +A row's role control and remove action SHALL be disabled with an accessible explanation when the server would reject the action (GAM-UI-08): the user is the last remaining owner, or the current caller is not an owner and the row is an owner. The table SHALL show localized empty, loading, no-results, and error states and SHALL expose a manual refresh consistent with other gateway resource tables. #### Scenario: Access rows render user name, user ID, and role -- GIVEN the access list returns an owner (the creator), another owner, one admin, and one user +- GIVEN the access list returns two owners, one admin, and one user - WHEN the table renders - THEN each row SHALL show the display name, username, and role (Owner/Admin/User) -- AND the creator's row SHALL carry the Creator marker --- @@ -207,7 +206,7 @@ The API adapter SHALL map the access endpoints (GAM-03 through GAM-09) to domain The access list SHALL load through TanStack Query keyed by gateway id and the active search/role/pagination parameters, with cancellation via request signals. Mutations (grant, change, revoke) SHALL invalidate the access list on success so the table reflects server state. The query SHALL only run while the tab is active. -All user-visible strings (tab label, column headers, filter labels, **Find people...**, **Add users**, role names and descriptions, creator marker, confirmations, and error/empty/no-results states) SHALL be defined with `defineMessages` and extracted into the web-console locale catalog. User-supplied display names used as fallbacks SHALL be passed through localized presentation helpers, not concatenated into English strings. +All user-visible strings (tab label, column headers, filter labels, **Find people...**, **Add users**, role names and descriptions, confirmations, and error/empty/no-results states) SHALL be defined with `defineMessages` and extracted into the web-console locale catalog. User-supplied display names used as fallbacks SHALL be passed through localized presentation helpers, not concatenated into English strings. #### Scenario: Mutations refresh the list @@ -219,9 +218,9 @@ All user-visible strings (tab label, column headers, filter labels, **Find peopl ### Requirement: GAM-UI-11 -- Verification -The gateway management UI package SHALL include unit/component tests (running the use cases against in-memory/fake adapters) covering: table rendering with creator marking, sole-owner disabled controls, and owner rows disabled for non-owner callers; search and role filtering across the three tiers; the directory typeahead including a never-signed-in candidate; the radio role modal grant including the owner-only Owner option; inline role change; remove-access confirmation; viewer read-only presentation; and probe emission (one started + one terminal per operation, including `denied`/`conflicted`). +The gateway management UI package SHALL include unit/component tests (running the use cases against in-memory/fake adapters) covering: table rendering, sole-owner disabled controls, and owner rows disabled for non-owner callers; search and role filtering across the three tiers; the directory typeahead including a never-signed-in candidate; the radio role modal grant including the owner-only Owner option; inline role change; remove-access confirmation; viewer read-only presentation; and probe emission (one started + one terminal per operation, including `denied`/`conflicted`). -The production API adapter SHALL have contract tests mapping the access endpoints and their error codes to domain types. Storybook/fixtures SHALL include an access list with two owners (one the creator), an admin, and a user. +The production API adapter SHALL have contract tests mapping the access endpoints and their error codes to domain types. Storybook/fixtures SHALL include an access list with two owners, an admin, and a user. #### Scenario: CI exercises last-owner protection and probes in the UI From ab6224efa1a291ecb1a3320c763cc8548fe28ea7 Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Thu, 8 Oct 2026 20:29:53 -0400 Subject: [PATCH 22/23] fix(sdk-generator): emit absolute paths for scoped SDK clients The scoped-client generator built paths via relativeAPIPath, stripping the /api/hypershell prefix, while the base HTTP clients concatenate baseURL + path with no prefix. Every generated GatewayAccess and ServiceAccount SDK call therefore targeted /v1/... and 404'd. This was reintroduced by the origin/main merge, which re-adopted the relative model for scoped resources. Build scoped paths from the absolute collection/item/directory paths, matching the non-scoped builder and the server routes, and regenerate the Go and TypeScript SDKs. Tighten the scoped-path test to require the full /api/hypershell prefix so a relative-path regression fails CI. Co-Authored-By: Claude Opus 4.8 --- .../sdk-go/client/gateway_access_api.go | 10 ++++---- .../open_shell_gateway_service_account_api.go | 10 ++++---- .../sdk-typescript/src/gateway_access_api.ts | 10 ++++---- .../open_shell_gateway_service_account_api.ts | 10 ++++---- scripts/sdk-generator/parser.go | 23 +++++++------------ scripts/sdk-generator/parser_test.go | 6 ++++- 6 files changed, 33 insertions(+), 36 deletions(-) diff --git a/components/sdk-go/client/gateway_access_api.go b/components/sdk-go/client/gateway_access_api.go index 03d98a24b..026cc020c 100644 --- a/components/sdk-go/client/gateway_access_api.go +++ b/components/sdk-go/client/gateway_access_api.go @@ -28,14 +28,14 @@ func (a *GatewayAccessAPI) Create(ctx context.Context, gatewayID string, request return nil, fmt.Errorf("marshal gateway_access create request: %w", err) } var result types.GatewayAccessGrantResponse - if _, err := a.client.doAny(ctx, http.MethodPost, fmt.Sprintf("/v1/gateways/%s/access", url.PathEscape(gatewayID)), body, &result, http.StatusCreated); err != nil { + if _, err := a.client.doAny(ctx, http.MethodPost, fmt.Sprintf("/api/hypershell/v1/gateways/%s/access", url.PathEscape(gatewayID)), body, &result, http.StatusCreated); err != nil { return nil, err } return &result, nil } func (a *GatewayAccessAPI) List(ctx context.Context, gatewayID string, opts *types.GatewayAccessListOptions) (*types.GatewayAccessList, error) { - path := fmt.Sprintf("/v1/gateways/%s/access", url.PathEscape(gatewayID)) + path := fmt.Sprintf("/api/hypershell/v1/gateways/%s/access", url.PathEscape(gatewayID)) if opts != nil { params := url.Values{} if opts.Page > 0 { @@ -69,7 +69,7 @@ func (a *GatewayAccessAPI) List(ctx context.Context, gatewayID string, opts *typ func (a *GatewayAccessAPI) Delete(ctx context.Context, gatewayID string, userID string) (*types.GatewayAccessListItem, error) { var result types.GatewayAccessListItem - status, err := a.client.doAny(ctx, http.MethodDelete, fmt.Sprintf("/v1/gateways/%s/access/%s", url.PathEscape(gatewayID), url.PathEscape(userID)), nil, &result, http.StatusNoContent, http.StatusAccepted) + status, err := a.client.doAny(ctx, http.MethodDelete, fmt.Sprintf("/api/hypershell/v1/gateways/%s/access/%s", url.PathEscape(gatewayID), url.PathEscape(userID)), nil, &result, http.StatusNoContent, http.StatusAccepted) if err != nil { return nil, err } @@ -85,14 +85,14 @@ func (a *GatewayAccessAPI) Update(ctx context.Context, gatewayID string, userID return nil, fmt.Errorf("marshal gateway_access update request: %w", err) } var result types.GatewayAccessGrantResponse - if _, err := a.client.doAny(ctx, http.MethodPatch, fmt.Sprintf("/v1/gateways/%s/access/%s", url.PathEscape(gatewayID), url.PathEscape(userID)), body, &result, http.StatusOK); err != nil { + if _, err := a.client.doAny(ctx, http.MethodPatch, fmt.Sprintf("/api/hypershell/v1/gateways/%s/access/%s", url.PathEscape(gatewayID), url.PathEscape(userID)), body, &result, http.StatusOK); err != nil { return nil, err } return &result, nil } func (a *GatewayAccessAPI) SearchDirectory(ctx context.Context, gatewayID string, search string) (*types.GatewayDirectoryUserList, error) { - path := fmt.Sprintf("/v1/gateways/%s/access/directory", url.PathEscape(gatewayID)) + path := fmt.Sprintf("/api/hypershell/v1/gateways/%s/access/directory", url.PathEscape(gatewayID)) if search != "" { params := url.Values{} params.Set("search", search) diff --git a/components/sdk-go/client/open_shell_gateway_service_account_api.go b/components/sdk-go/client/open_shell_gateway_service_account_api.go index c93068930..7e659a666 100644 --- a/components/sdk-go/client/open_shell_gateway_service_account_api.go +++ b/components/sdk-go/client/open_shell_gateway_service_account_api.go @@ -28,7 +28,7 @@ func (a *OpenShellGatewayServiceAccountAPI) Create(ctx context.Context, gatewayI return nil, fmt.Errorf("marshal open_shell_gateway_service_account create request: %w", err) } var result types.OpenShellGatewayServiceAccountCreateResponse - if _, err := a.client.doAny(ctx, http.MethodPost, fmt.Sprintf("/v1/gateways/%s/service_accounts", url.PathEscape(gatewayID)), body, &result, http.StatusCreated); err != nil { + if _, err := a.client.doAny(ctx, http.MethodPost, fmt.Sprintf("/api/hypershell/v1/gateways/%s/service_accounts", url.PathEscape(gatewayID)), body, &result, http.StatusCreated); err != nil { return nil, err } return &result, nil @@ -36,14 +36,14 @@ func (a *OpenShellGatewayServiceAccountAPI) Create(ctx context.Context, gatewayI func (a *OpenShellGatewayServiceAccountAPI) Get(ctx context.Context, gatewayID string, serviceAccountID string) (*types.OpenShellGatewayServiceAccountGetResponse, error) { var result types.OpenShellGatewayServiceAccountGetResponse - if _, err := a.client.doAny(ctx, http.MethodGet, fmt.Sprintf("/v1/gateways/%s/service_accounts/%s", url.PathEscape(gatewayID), url.PathEscape(serviceAccountID)), nil, &result, http.StatusOK); err != nil { + if _, err := a.client.doAny(ctx, http.MethodGet, fmt.Sprintf("/api/hypershell/v1/gateways/%s/service_accounts/%s", url.PathEscape(gatewayID), url.PathEscape(serviceAccountID)), nil, &result, http.StatusOK); err != nil { return nil, err } return &result, nil } func (a *OpenShellGatewayServiceAccountAPI) List(ctx context.Context, gatewayID string, opts *types.OpenShellGatewayServiceAccountListOptions) (*types.OpenShellGatewayServiceAccountList, error) { - path := fmt.Sprintf("/v1/gateways/%s/service_accounts", url.PathEscape(gatewayID)) + path := fmt.Sprintf("/api/hypershell/v1/gateways/%s/service_accounts", url.PathEscape(gatewayID)) if opts != nil { params := url.Values{} if opts.Page > 0 { @@ -77,7 +77,7 @@ func (a *OpenShellGatewayServiceAccountAPI) List(ctx context.Context, gatewayID func (a *OpenShellGatewayServiceAccountAPI) Revoke(ctx context.Context, gatewayID string, serviceAccountID string) (*types.OpenShellGatewayServiceAccountListItem, error) { var result types.OpenShellGatewayServiceAccountListItem - if _, err := a.client.doAny(ctx, http.MethodPost, fmt.Sprintf("/v1/gateways/%s/service_accounts/%s", url.PathEscape(gatewayID), url.PathEscape(serviceAccountID))+"/revoke", nil, &result, http.StatusOK, http.StatusAccepted); err != nil { + if _, err := a.client.doAny(ctx, http.MethodPost, fmt.Sprintf("/api/hypershell/v1/gateways/%s/service_accounts/%s", url.PathEscape(gatewayID), url.PathEscape(serviceAccountID))+"/revoke", nil, &result, http.StatusOK, http.StatusAccepted); err != nil { return nil, err } return &result, nil @@ -85,7 +85,7 @@ func (a *OpenShellGatewayServiceAccountAPI) Revoke(ctx context.Context, gatewayI func (a *OpenShellGatewayServiceAccountAPI) Delete(ctx context.Context, gatewayID string, serviceAccountID string) (*types.OpenShellGatewayServiceAccountListItem, error) { var result types.OpenShellGatewayServiceAccountListItem - status, err := a.client.doAny(ctx, http.MethodDelete, fmt.Sprintf("/v1/gateways/%s/service_accounts/%s", url.PathEscape(gatewayID), url.PathEscape(serviceAccountID)), nil, &result, http.StatusNoContent, http.StatusAccepted) + status, err := a.client.doAny(ctx, http.MethodDelete, fmt.Sprintf("/api/hypershell/v1/gateways/%s/service_accounts/%s", url.PathEscape(gatewayID), url.PathEscape(serviceAccountID)), nil, &result, http.StatusNoContent, http.StatusAccepted) if err != nil { return nil, err } diff --git a/components/sdk-typescript/src/gateway_access_api.ts b/components/sdk-typescript/src/gateway_access_api.ts index b14cf8a95..9b2f74a14 100644 --- a/components/sdk-typescript/src/gateway_access_api.ts +++ b/components/sdk-typescript/src/gateway_access_api.ts @@ -18,7 +18,7 @@ export class GatewayAccessAPI { constructor(private readonly config: SDKClientConfig) {} async create(gatewayId: string, data: GatewayAccessGrantRequest, opts?: RequestOptions): Promise { - return sdkFetch(this.config, 'POST', `/v1/gateways/${encodeURIComponent(gatewayId)}/access`, data, opts); + return sdkFetch(this.config, 'POST', `/api/hypershell/v1/gateways/${encodeURIComponent(gatewayId)}/access`, data, opts); } async list(gatewayId: string, listOpts?: GatewayAccessListOptions, opts?: RequestOptions): Promise { @@ -42,16 +42,16 @@ export class GatewayAccessAPI { params.set('order', String(listOpts.order)); } const query = params.toString(); - const path = `/v1/gateways/${encodeURIComponent(gatewayId)}/access` + (query ? `?${query}` : ''); + const path = `/api/hypershell/v1/gateways/${encodeURIComponent(gatewayId)}/access` + (query ? `?${query}` : ''); return sdkFetch(this.config, 'GET', path, undefined, opts); } async delete(gatewayId: string, userId: string, opts?: RequestOptions): Promise { - return sdkFetch(this.config, 'DELETE', `/v1/gateways/${encodeURIComponent(gatewayId)}/access/${encodeURIComponent(userId)}`, undefined, opts); + return sdkFetch(this.config, 'DELETE', `/api/hypershell/v1/gateways/${encodeURIComponent(gatewayId)}/access/${encodeURIComponent(userId)}`, undefined, opts); } async update(gatewayId: string, userId: string, data: GatewayAccessChangeRoleRequest, opts?: RequestOptions): Promise { - return sdkFetch(this.config, 'PATCH', `/v1/gateways/${encodeURIComponent(gatewayId)}/access/${encodeURIComponent(userId)}`, data, opts); + return sdkFetch(this.config, 'PATCH', `/api/hypershell/v1/gateways/${encodeURIComponent(gatewayId)}/access/${encodeURIComponent(userId)}`, data, opts); } async searchDirectory(gatewayId: string, search?: string, opts?: RequestOptions): Promise { @@ -60,7 +60,7 @@ export class GatewayAccessAPI { params.set('search', search); } const query = params.toString(); - const path = `/v1/gateways/${encodeURIComponent(gatewayId)}/access/directory` + (query ? `?${query}` : ''); + const path = `/api/hypershell/v1/gateways/${encodeURIComponent(gatewayId)}/access/directory` + (query ? `?${query}` : ''); return sdkFetch(this.config, 'GET', path, undefined, opts); } diff --git a/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts b/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts index c6e21ab0f..227b0e204 100644 --- a/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts +++ b/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts @@ -17,11 +17,11 @@ export class OpenShellGatewayServiceAccountAPI { constructor(private readonly config: SDKClientConfig) {} async create(gatewayId: string, data: OpenShellGatewayServiceAccountCreateRequest, opts?: RequestOptions): Promise { - return sdkFetch(this.config, 'POST', `/v1/gateways/${encodeURIComponent(gatewayId)}/service_accounts`, data, opts); + return sdkFetch(this.config, 'POST', `/api/hypershell/v1/gateways/${encodeURIComponent(gatewayId)}/service_accounts`, data, opts); } async get(gatewayId: string, serviceAccountId: string, opts?: RequestOptions): Promise { - return sdkFetch(this.config, 'GET', `/v1/gateways/${encodeURIComponent(gatewayId)}/service_accounts/${encodeURIComponent(serviceAccountId)}`, undefined, opts); + return sdkFetch(this.config, 'GET', `/api/hypershell/v1/gateways/${encodeURIComponent(gatewayId)}/service_accounts/${encodeURIComponent(serviceAccountId)}`, undefined, opts); } async list(gatewayId: string, listOpts?: OpenShellGatewayServiceAccountListOptions, opts?: RequestOptions): Promise { @@ -45,16 +45,16 @@ export class OpenShellGatewayServiceAccountAPI { params.set('order', String(listOpts.order)); } const query = params.toString(); - const path = `/v1/gateways/${encodeURIComponent(gatewayId)}/service_accounts` + (query ? `?${query}` : ''); + const path = `/api/hypershell/v1/gateways/${encodeURIComponent(gatewayId)}/service_accounts` + (query ? `?${query}` : ''); return sdkFetch(this.config, 'GET', path, undefined, opts); } async revoke(gatewayId: string, serviceAccountId: string, opts?: RequestOptions): Promise { - return sdkFetch(this.config, 'POST', `/v1/gateways/${encodeURIComponent(gatewayId)}/service_accounts/${encodeURIComponent(serviceAccountId)}` + '/revoke', undefined, opts); + return sdkFetch(this.config, 'POST', `/api/hypershell/v1/gateways/${encodeURIComponent(gatewayId)}/service_accounts/${encodeURIComponent(serviceAccountId)}` + '/revoke', undefined, opts); } async delete(gatewayId: string, serviceAccountId: string, opts?: RequestOptions): Promise { - return sdkFetch(this.config, 'DELETE', `/v1/gateways/${encodeURIComponent(gatewayId)}/service_accounts/${encodeURIComponent(serviceAccountId)}`, undefined, opts); + return sdkFetch(this.config, 'DELETE', `/api/hypershell/v1/gateways/${encodeURIComponent(gatewayId)}/service_accounts/${encodeURIComponent(serviceAccountId)}`, undefined, opts); } async *listAll(gatewayId: string, size: number = 20, opts?: RequestOptions): AsyncGenerator { diff --git a/scripts/sdk-generator/parser.go b/scripts/sdk-generator/parser.go index a405a2c5c..f1bd30cd6 100644 --- a/scripts/sdk-generator/parser.go +++ b/scripts/sdk-generator/parser.go @@ -35,7 +35,7 @@ func parseSpec(specPath, apiPrefix string) (*Spec, error) { resources = append(resources, resource) } for _, view := range scopedCollectionViews(document, apiPrefix) { - resource, err := projectScopedResource(document, view, apiPrefix) + resource, err := projectScopedResource(document, view) if err != nil { return nil, fmt.Errorf("project scoped resource %s: %w", view.Path, err) } @@ -45,7 +45,7 @@ func parseSpec(specPath, apiPrefix string) (*Spec, error) { return &Spec{Resources: resources, APIPrefix: apiPrefix}, nil } -func projectScopedResource(document *ir.Document, collection *ir.ResourceView, apiPrefix string) (Resource, error) { +func projectScopedResource(document *ir.Document, collection *ir.ResourceView) (Resource, error) { listOperation := operationAt(document, collection.Path, "GET") createOperation := operationAt(document, collection.Path, "POST") if listOperation == nil || createOperation == nil { @@ -154,8 +154,6 @@ func projectScopedResource(document *ir.Document, collection *ir.ResourceView, a name + "ListOptions", }) - collectionRelative := relativeAPIPath(collection.Path, apiPrefix) - itemRelative := relativeAPIPath(itemPath, apiPrefix) resource := Resource{ Name: name, Plural: resourcePlural(name), @@ -163,10 +161,10 @@ func projectScopedResource(document *ir.Document, collection *ir.ResourceView, a Scoped: true, ScopeParameters: scopeParameters, ItemParameter: &itemParameter, - GoCollectionPath: goPathExpression(collectionRelative, scopeParameters, nil), - GoItemPath: goPathExpression(itemRelative, scopeParameters, &itemParameter), - TSCollectionPath: tsPathExpression(collectionRelative, scopeParameters, nil), - TSItemPath: tsPathExpression(itemRelative, scopeParameters, &itemParameter), + GoCollectionPath: goPathExpression(collection.Path, scopeParameters, nil), + GoItemPath: goPathExpression(itemPath, scopeParameters, &itemParameter), + TSCollectionPath: tsPathExpression(collection.Path, scopeParameters, nil), + TSItemPath: tsPathExpression(itemPath, scopeParameters, &itemParameter), ListType: listType, ItemType: itemType, CreateRequestType: createRequestType, @@ -185,9 +183,8 @@ func projectScopedResource(document *ir.Document, collection *ir.ResourceView, a TSImports: tsImports, } if hasDirectorySearch { - directoryRelative := relativeAPIPath(directoryPath, apiPrefix) - resource.GoDirectoryPath = goPathExpression(directoryRelative, scopeParameters, nil) - resource.TSDirectoryPath = tsPathExpression(directoryRelative, scopeParameters, nil) + resource.GoDirectoryPath = goPathExpression(directoryPath, scopeParameters, nil) + resource.TSDirectoryPath = tsPathExpression(directoryPath, scopeParameters, nil) } if hasRevoke { resource.Actions = []string{"revoke"} @@ -480,10 +477,6 @@ func lastPathParameter(path string) string { return strings.Trim(last, "{}") } -func relativeAPIPath(path, apiPrefix string) string { - return "/" + strings.TrimPrefix(strings.TrimPrefix(path, strings.TrimSuffix(apiPrefix, "/")), "/") -} - func goPathExpression(path string, scope []PathParameter, item *PathParameter) string { format := path arguments := make([]string, 0, len(scope)+1) diff --git a/scripts/sdk-generator/parser_test.go b/scripts/sdk-generator/parser_test.go index 0c2d1a514..6eb171148 100644 --- a/scripts/sdk-generator/parser_test.go +++ b/scripts/sdk-generator/parser_test.go @@ -30,7 +30,11 @@ func TestParseSpecProjectsScopedServiceAccountResource(t *testing.T) { if len(resource.ScopeParameters) != 1 || resource.ScopeParameters[0].Name != "gateway_id" { t.Fatalf("scope parameters = %#v, want gateway_id", resource.ScopeParameters) } - if !strings.Contains(resource.GoCollectionPath, "/gateways/%s/service_accounts") { + // Scoped paths must be absolute, matching the server routes and the + // non-scoped clients; the base HTTP clients add no prefix. Asserting the + // full /api/hypershell prefix (not just the suffix) guards against a + // relative-path regression like the one in #447. + if !strings.Contains(resource.GoCollectionPath, "/api/hypershell/v1/gateways/%s/service_accounts") { t.Fatalf("Go collection path = %q", resource.GoCollectionPath) } if !strings.Contains(resource.TSItemPath, "encodeURIComponent(serviceAccountId)") { From 4d2043dc8ed130fde5a636f20779e808c222a11a Mon Sep 17 00:00:00 2001 From: Brandon Squizzato Date: Fri, 9 Oct 2026 10:08:45 -0400 Subject: [PATCH 23/23] fix(e2e): send placement intent in access-management gateway creates The access-management area built two gateway create bodies inline with the old cluster_id shape and no placement, so the API rejected them with HTTP 400 (required property placement). Reuse e2e_gateway_create_body, which already emits the deployment's placement intent, at both call sites. Co-Authored-By: Claude Opus 4.8 --- tests/e2e/e2e-openshell.sh | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/tests/e2e/e2e-openshell.sh b/tests/e2e/e2e-openshell.sh index 19578bf24..310a137f5 100755 --- a/tests/e2e/e2e-openshell.sh +++ b/tests/e2e/e2e-openshell.sh @@ -2315,13 +2315,7 @@ os.chmod(os.path.join(d,'metadata.json'),0o600); os.chmod(os.path.join(d,'oidc_t # namespace-GC assertion). A delete check needs no provisioning wait. if [[ -n "$GAM_OWNER2_API_TOKEN" ]]; then GAM_GW_NAME="e2e-gam-gw-$(date +%s | tail -c5)" - GAM_GW_BODY=$(GW_NAME="$GAM_GW_NAME" E2E_OIDC_ISSUER="$E2E_OIDC_ISSUER" \ - E2E_OIDC_CLIENT_ID="$E2E_OIDC_CLIENT_ID" E2E_CLUSTER_ID="${E2E_CLUSTER_ID:-}" python3 -c " -import json, os -print(json.dumps({'name':os.environ['GW_NAME'],'cluster_id':os.environ['E2E_CLUSTER_ID'], - 'oidc':json.dumps({'issuer':os.environ['E2E_OIDC_ISSUER'],'audience':os.environ['E2E_OIDC_CLIENT_ID'], - 'roles_claim':'groups','admin_role':'hypershell-admins','user_role':'hypershell-users'}), - 'route':json.dumps({'enabled':True})}))") + GAM_GW_BODY=$(e2e_gateway_create_body "$GAM_GW_NAME") GAM_GW_ID=$(api_curl -X POST "${API_HOST}/api/hypershell/v1/gateways" \ -H "Content-Type: application/json" -d "$GAM_GW_BODY" 2>/dev/null \ | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) @@ -2367,13 +2361,7 @@ print(json.dumps({'name':os.environ['GW_NAME'],'cluster_id':os.environ['E2E_CLUS # 1. ${E2E_GAM_USER} creates a gateway (kind grants gateway:creator by default) # and becomes its first and only gateway:owner. GAM_PA_GW_NAME="e2e-padmin-gw-$(date +%s | tail -c5)" - GAM_PA_GW_BODY=$(GW_NAME="$GAM_PA_GW_NAME" E2E_OIDC_ISSUER="$E2E_OIDC_ISSUER" \ - E2E_OIDC_CLIENT_ID="$E2E_OIDC_CLIENT_ID" E2E_CLUSTER_ID="${E2E_CLUSTER_ID:-}" python3 -c " -import json, os -print(json.dumps({'name':os.environ['GW_NAME'],'cluster_id':os.environ['E2E_CLUSTER_ID'], - 'oidc':json.dumps({'issuer':os.environ['E2E_OIDC_ISSUER'],'audience':os.environ['E2E_OIDC_CLIENT_ID'], - 'roles_claim':'groups','admin_role':'hypershell-admins','user_role':'hypershell-users'}), - 'route':json.dumps({'enabled':True})}))") + GAM_PA_GW_BODY=$(e2e_gateway_create_body "$GAM_PA_GW_NAME") show_cmd "POST ${API_HOST}/api/hypershell/v1/gateways (as ${E2E_GAM_USER}) -> expect 201" GAM_PA_RESP_F=$(mktemp) GAM_PA_GW_STATUS=$(_driver_curl -o "$GAM_PA_RESP_F" -w '%{http_code}' -X POST \