diff --git a/components/api-server/cmd/hypershell/main.go b/components/api-server/cmd/hypershell/main.go index 24b858c67..e83042d08 100644 --- a/components/api-server/cmd/hypershell/main.go +++ b/components/api-server/cmd/hypershell/main.go @@ -13,6 +13,7 @@ import ( _ "github.com/openshift-online/hypershell/components/api-server/cmd/hypershell/environments" _ "github.com/openshift-online/hypershell/components/api-server/plugins/agentRuntimes" + _ "github.com/openshift-online/hypershell/components/api-server/plugins/gatewayAccess" _ "github.com/openshift-online/hypershell/components/api-server/plugins/gateways" _ "github.com/openshift-online/hypershell/components/api-server/plugins/inferenceRoutes" _ "github.com/openshift-online/hypershell/components/api-server/plugins/managedClusters" diff --git a/components/api-server/openapi/openapi.gatewayAccess.yaml b/components/api-server/openapi/openapi.gatewayAccess.yaml new file mode 100644 index 000000000..124f3387f --- /dev/null +++ b/components/api-server/openapi/openapi.gatewayAccess.yaml @@ -0,0 +1,317 @@ +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 + - 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' + 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.gateways.yaml b/components/api-server/openapi/openapi.gateways.yaml index 9f559cf2a..9f027685a 100644 --- a/components/api-server/openapi/openapi.gateways.yaml +++ b/components/api-server/openapi/openapi.gateways.yaml @@ -350,6 +350,14 @@ 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. + 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/openapi/openapi.yaml b/components/api-server/openapi/openapi.yaml index 4d99919ad..a0c75fcf5 100644 --- a/components/api-server/openapi/openapi.yaml +++ b/components/api-server/openapi/openapi.yaml @@ -56,6 +56,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}' /api/hypershell/ext/agent_runtimes: $ref: 'openapi.agentRuntimes.yaml#/paths/~1api~1hypershell~1ext~1agent_runtimes' /api/hypershell/ext/agent_runtimes/{id}: @@ -176,6 +182,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' AgentRuntime: $ref: 'openapi.agentRuntimes.yaml#/components/schemas/AgentRuntime' AgentRuntimeList: 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 fa0ab9bc7..01d4c8f64 100644 --- a/components/api-server/pkg/api/openapi/.openapi-generator/FILES +++ b/components/api-server/pkg/api/openapi/.openapi-generator/FILES @@ -12,8 +12,17 @@ docs/AgentRuntimePatchRequest.md 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/GatewayPatchRequest.md docs/GatewayPlacementAvailability.md @@ -64,8 +73,17 @@ model_agent_runtime_list.go model_agent_runtime_patch_request.go 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_patch_request.go model_gateway_placement_availability.go diff --git a/components/api-server/pkg/api/openapi/README.md b/components/api-server/pkg/api/openapi/README.md index f157380f6..ca50970ed 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* | [**CreateAgentRuntime**](docs/DefaultAPI.md#createagentruntime) | **Post** /api/hypershell/ext/agent_runtimes | Create a new agent runtime *DefaultAPI* | [**CreateGateway**](docs/DefaultAPI.md#creategateway) | **Post** /api/hypershell/v1/gateways | Create a new gateway *DefaultAPI* | [**CreateGatewayServiceAccount**](docs/DefaultAPI.md#creategatewayserviceaccount) | **Post** /api/hypershell/v1/gateways/{gateway_id}/service_accounts | Create an OpenShell gateway service account @@ -112,7 +113,9 @@ Class | Method | HTTP request | Description *DefaultAPI* | [**GetSandboxTemplate**](docs/DefaultAPI.md#getsandboxtemplate) | **Get** /api/hypershell/ext/sandbox_templates/{id} | Get a sandbox template by ID *DefaultAPI* | [**GetSecretSource**](docs/DefaultAPI.md#getsecretsource) | **Get** /api/hypershell/ext/secret_sources/{id} | Get a secret source 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* | [**ListAgentRuntimes**](docs/DefaultAPI.md#listagentruntimes) | **Get** /api/hypershell/ext/agent_runtimes | Returns a list of agent runtimes +*DefaultAPI* | [**ListGatewayAccess**](docs/DefaultAPI.md#listgatewayaccess) | **Get** /api/hypershell/v1/gateways/{gateway_id}/access | List access grants on a gateway *DefaultAPI* | [**ListGatewayServiceAccounts**](docs/DefaultAPI.md#listgatewayserviceaccounts) | **Get** /api/hypershell/v1/gateways/{gateway_id}/service_accounts | List OpenShell gateway service accounts *DefaultAPI* | [**ListGateways**](docs/DefaultAPI.md#listgateways) | **Get** /api/hypershell/v1/gateways | Returns a list of gateways *DefaultAPI* | [**ListInferenceRoutes**](docs/DefaultAPI.md#listinferenceroutes) | **Get** /api/hypershell/ext/inference_routes | Returns a list of inference routes @@ -125,7 +128,9 @@ Class | Method | HTTP request | Description *DefaultAPI* | [**ListSecretSources**](docs/DefaultAPI.md#listsecretsources) | **Get** /api/hypershell/ext/secret_sources | Returns a list of secret sources *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* | [**UpdateAgentRuntime**](docs/DefaultAPI.md#updateagentruntime) | **Patch** /api/hypershell/ext/agent_runtimes/{id} | Update an agent runtime *DefaultAPI* | [**UpdateGateway**](docs/DefaultAPI.md#updategateway) | **Patch** /api/hypershell/v1/gateways/{id} | Update an gateway *DefaultAPI* | [**UpdateManagedCluster**](docs/DefaultAPI.md#updatemanagedcluster) | **Patch** /api/hypershell/v1/managed_clusters/{id} | Update an managedCluster @@ -142,8 +147,17 @@ Class | Method | HTTP request | Description - [AgentRuntimePatchRequest](docs/AgentRuntimePatchRequest.md) - [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) - [GatewayPatchRequest](docs/GatewayPatchRequest.md) - [GatewayPlacementAvailability](docs/GatewayPlacementAvailability.md) diff --git a/components/api-server/pkg/api/openapi/api/openapi.yaml b/components/api-server/pkg/api/openapi/api/openapi.yaml index e739bc88c..d587bb2d6 100644 --- a/components/api-server/pkg/api/openapi/api/openapi.yaml +++ b/components/api-server/pkg/api/openapi/api/openapi.yaml @@ -1344,6 +1344,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 /api/hypershell/ext/agent_runtimes: get: operationId: listAgentRuntimes @@ -2748,6 +3022,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 @@ -3097,6 +3385,20 @@ 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 + 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 @@ -3110,6 +3412,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 @@ -3128,6 +3431,7 @@ components: message: message kind: kind console_address: console_address + can_edit: true route_address: route_address oidc: oidc created_by: created_by @@ -3164,6 +3468,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 @@ -3182,6 +3487,7 @@ components: message: message kind: kind console_address: console_address + can_edit: true route_address: route_address oidc: oidc created_by: created_by @@ -3199,6 +3505,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 @@ -3217,6 +3524,7 @@ components: message: message kind: kind console_address: console_address + can_edit: true route_address: route_address oidc: oidc created_by: created_by @@ -3921,6 +4229,187 @@ 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 + 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" + granted_at: + format: date-time + type: string + required: + - granted_at + - 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 + 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 + 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 + 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 AgentRuntime: allOf: - $ref: "#/components/schemas/ObjectReference" diff --git a/components/api-server/pkg/api/openapi/api_default.go b/components/api-server/pkg/api/openapi/api_default.go index aa7c563da..2692309eb 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 ApiCreateAgentRuntimeRequest struct { ctx context.Context ApiService *DefaultAPIService @@ -4367,108 +4516,65 @@ func (a *DefaultAPIService) GetUserExecute(r ApiGetUserRequest) (*User, *http.Re return localVarReturnValue, localVarHTTPResponse, nil } -type ApiListAgentRuntimesRequest 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 ApiListAgentRuntimesRequest) Page(page int32) ApiListAgentRuntimesRequest { - r.page = &page - return r -} - -// Maximum number of records to return -func (r ApiListAgentRuntimesRequest) Size(size int32) ApiListAgentRuntimesRequest { - r.size = &size - return r -} - -// Specifies the search criteria -func (r ApiListAgentRuntimesRequest) Search(search string) ApiListAgentRuntimesRequest { - r.search = &search - return r -} - -// Specifies the order by criteria -func (r ApiListAgentRuntimesRequest) OrderBy(orderBy string) ApiListAgentRuntimesRequest { - 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 ApiListAgentRuntimesRequest) Fields(fields string) ApiListAgentRuntimesRequest { - r.fields = &fields +func (r ApiGrantGatewayAccessRequest) GatewayAccessGrantRequest(gatewayAccessGrantRequest GatewayAccessGrantRequest) ApiGrantGatewayAccessRequest { + r.gatewayAccessGrantRequest = &gatewayAccessGrantRequest return r } -func (r ApiListAgentRuntimesRequest) Execute() (*AgentRuntimeList, *http.Response, error) { - return r.ApiService.ListAgentRuntimesExecute(r) +func (r ApiGrantGatewayAccessRequest) Execute() (*GatewayAccessGrantResponse, *http.Response, error) { + return r.ApiService.GrantGatewayAccessExecute(r) } /* -ListAgentRuntimes Returns a list of agent runtimes +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 ApiListAgentRuntimesRequest + @param gatewayId Selected Gateway ID + @return ApiGrantGatewayAccessRequest */ -func (a *DefaultAPIService) ListAgentRuntimes(ctx context.Context) ApiListAgentRuntimesRequest { - return ApiListAgentRuntimesRequest{ +func (a *DefaultAPIService) GrantGatewayAccess(ctx context.Context, gatewayId string) ApiGrantGatewayAccessRequest { + return ApiGrantGatewayAccessRequest{ ApiService: a, ctx: ctx, + gatewayId: gatewayId, } } // Execute executes the request // -// @return AgentRuntimeList -func (a *DefaultAPIService) ListAgentRuntimesExecute(r ApiListAgentRuntimesRequest) (*AgentRuntimeList, *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 *AgentRuntimeList + localVarReturnValue *GatewayAccessGrantResponse ) - localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DefaultAPIService.ListAgentRuntimes") + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DefaultAPIService.GrantGatewayAccess") if err != nil { return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} } - localVarPath := localBasePath + "/api/hypershell/ext/agent_runtimes" + 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) @@ -4484,6 +4590,8 @@ func (a *DefaultAPIService) ListAgentRuntimesExecute(r ApiListAgentRuntimesReque 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 @@ -4506,7 +4614,40 @@ func (a *DefaultAPIService) ListAgentRuntimesExecute(r ApiListAgentRuntimesReque body: localVarBody, error: localVarHTTPResponse.Status, } - if localVarHTTPResponse.StatusCode == 500 { + 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 + } + if localVarHTTPResponse.StatusCode == 503 { var v Error err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) if err != nil { @@ -4531,43 +4672,411 @@ func (a *DefaultAPIService) ListAgentRuntimesExecute(r ApiListAgentRuntimesReque return localVarReturnValue, localVarHTTPResponse, nil } -type ApiListGatewayServiceAccountsRequest struct { +type ApiListAgentRuntimesRequest struct { ctx context.Context ApiService *DefaultAPIService - gatewayId string page *int32 size *int32 - status *OpenShellGatewayServiceAccountStatus search *string - sort *string - order *string + orderBy *string + fields *string } // Page number of record list when record list exceeds specified page size -func (r ApiListGatewayServiceAccountsRequest) Page(page int32) ApiListGatewayServiceAccountsRequest { +func (r ApiListAgentRuntimesRequest) Page(page int32) ApiListAgentRuntimesRequest { r.page = &page return r } // Maximum number of records to return -func (r ApiListGatewayServiceAccountsRequest) Size(size int32) ApiListGatewayServiceAccountsRequest { +func (r ApiListAgentRuntimesRequest) Size(size int32) ApiListAgentRuntimesRequest { r.size = &size return r } -func (r ApiListGatewayServiceAccountsRequest) Status(status OpenShellGatewayServiceAccountStatus) ApiListGatewayServiceAccountsRequest { - r.status = &status - return r -} - // Specifies the search criteria -func (r ApiListGatewayServiceAccountsRequest) Search(search string) ApiListGatewayServiceAccountsRequest { +func (r ApiListAgentRuntimesRequest) Search(search string) ApiListAgentRuntimesRequest { r.search = &search return r } -func (r ApiListGatewayServiceAccountsRequest) Sort(sort string) ApiListGatewayServiceAccountsRequest { - r.sort = &sort +// Specifies the order by criteria +func (r ApiListAgentRuntimesRequest) OrderBy(orderBy string) ApiListAgentRuntimesRequest { + r.orderBy = &orderBy + return r +} + +// Supplies a comma-separated list of fields to be returned +func (r ApiListAgentRuntimesRequest) Fields(fields string) ApiListAgentRuntimesRequest { + r.fields = &fields + return r +} + +func (r ApiListAgentRuntimesRequest) Execute() (*AgentRuntimeList, *http.Response, error) { + return r.ApiService.ListAgentRuntimesExecute(r) +} + +/* +ListAgentRuntimes Returns a list of agent runtimes + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return ApiListAgentRuntimesRequest +*/ +func (a *DefaultAPIService) ListAgentRuntimes(ctx context.Context) ApiListAgentRuntimesRequest { + return ApiListAgentRuntimesRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +// +// @return AgentRuntimeList +func (a *DefaultAPIService) ListAgentRuntimesExecute(r ApiListAgentRuntimesRequest) (*AgentRuntimeList, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *AgentRuntimeList + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DefaultAPIService.ListAgentRuntimes") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/api/hypershell/ext/agent_runtimes" + + 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 == 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 ApiListGatewayAccessRequest struct { + ctx context.Context + ApiService *DefaultAPIService + gatewayId string + page *int32 + size *int32 + role *GatewayAccessRole + search *string + sort *string + order *string +} + +// Page number of record list when record list exceeds specified page size +func (r ApiListGatewayAccessRequest) Page(page int32) ApiListGatewayAccessRequest { + r.page = &page + return r +} + +// Maximum number of records to return +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 ApiListGatewayAccessRequest) Search(search string) ApiListGatewayAccessRequest { + r.search = &search + return r +} + +func (r ApiListGatewayAccessRequest) Sort(sort string) ApiListGatewayAccessRequest { + r.sort = &sort + return r +} + +func (r ApiListGatewayAccessRequest) Order(order string) ApiListGatewayAccessRequest { + r.order = &order + return r +} + +func (r ApiListGatewayAccessRequest) Execute() (*GatewayAccessList, *http.Response, error) { + return r.ApiService.ListGatewayAccessExecute(r) +} + +/* +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(). + @param gatewayId Selected Gateway ID + @return ApiListGatewayAccessRequest +*/ +func (a *DefaultAPIService) ListGatewayAccess(ctx context.Context, gatewayId string) ApiListGatewayAccessRequest { + return ApiListGatewayAccessRequest{ + ApiService: a, + ctx: ctx, + gatewayId: gatewayId, + } +} + +// Execute executes the request +// +// @return GatewayAccessList +func (a *DefaultAPIService) ListGatewayAccessExecute(r ApiListGatewayAccessRequest) (*GatewayAccessList, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *GatewayAccessList + ) + + 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/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.role != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "role", r.role, "", "") + } + if r.search != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "search", r.search, "form", "") + } + 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 ApiListGatewayServiceAccountsRequest struct { + ctx context.Context + ApiService *DefaultAPIService + gatewayId string + page *int32 + size *int32 + status *OpenShellGatewayServiceAccountStatus + search *string + sort *string + order *string +} + +// Page number of record list when record list exceeds specified page size +func (r ApiListGatewayServiceAccountsRequest) Page(page int32) ApiListGatewayServiceAccountsRequest { + r.page = &page + return r +} + +// Maximum number of records to return +func (r ApiListGatewayServiceAccountsRequest) Size(size int32) ApiListGatewayServiceAccountsRequest { + r.size = &size + return r +} + +func (r ApiListGatewayServiceAccountsRequest) Status(status OpenShellGatewayServiceAccountStatus) ApiListGatewayServiceAccountsRequest { + r.status = &status + return r +} + +// Specifies the search criteria +func (r ApiListGatewayServiceAccountsRequest) Search(search string) ApiListGatewayServiceAccountsRequest { + r.search = &search + return r +} + +func (r ApiListGatewayServiceAccountsRequest) Sort(sort string) ApiListGatewayServiceAccountsRequest { + r.sort = &sort return r } @@ -6589,6 +7098,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 @@ -6716,6 +7340,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 ApiUpdateAgentRuntimeRequest 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 6c8a06334..fba4ac6fc 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 [**CreateAgentRuntime**](DefaultAPI.md#CreateAgentRuntime) | **Post** /api/hypershell/ext/agent_runtimes | Create a new agent runtime [**CreateGateway**](DefaultAPI.md#CreateGateway) | **Post** /api/hypershell/v1/gateways | Create a new gateway [**CreateGatewayServiceAccount**](DefaultAPI.md#CreateGatewayServiceAccount) | **Post** /api/hypershell/v1/gateways/{gateway_id}/service_accounts | Create an OpenShell gateway service account @@ -38,7 +39,9 @@ Method | HTTP request | Description [**GetSandboxTemplate**](DefaultAPI.md#GetSandboxTemplate) | **Get** /api/hypershell/ext/sandbox_templates/{id} | Get a sandbox template by ID [**GetSecretSource**](DefaultAPI.md#GetSecretSource) | **Get** /api/hypershell/ext/secret_sources/{id} | Get a secret source 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 [**ListAgentRuntimes**](DefaultAPI.md#ListAgentRuntimes) | **Get** /api/hypershell/ext/agent_runtimes | Returns a list of agent runtimes +[**ListGatewayAccess**](DefaultAPI.md#ListGatewayAccess) | **Get** /api/hypershell/v1/gateways/{gateway_id}/access | List access grants on a gateway [**ListGatewayServiceAccounts**](DefaultAPI.md#ListGatewayServiceAccounts) | **Get** /api/hypershell/v1/gateways/{gateway_id}/service_accounts | List OpenShell gateway service accounts [**ListGateways**](DefaultAPI.md#ListGateways) | **Get** /api/hypershell/v1/gateways | Returns a list of gateways [**ListInferenceRoutes**](DefaultAPI.md#ListInferenceRoutes) | **Get** /api/hypershell/ext/inference_routes | Returns a list of inference routes @@ -51,7 +54,9 @@ Method | HTTP request | Description [**ListSecretSources**](DefaultAPI.md#ListSecretSources) | **Get** /api/hypershell/ext/secret_sources | Returns a list of secret sources [**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 [**UpdateAgentRuntime**](DefaultAPI.md#UpdateAgentRuntime) | **Patch** /api/hypershell/ext/agent_runtimes/{id} | Update an agent runtime [**UpdateGateway**](DefaultAPI.md#UpdateGateway) | **Patch** /api/hypershell/v1/gateways/{id} | Update an gateway [**UpdateManagedCluster**](DefaultAPI.md#UpdateManagedCluster) | **Patch** /api/hypershell/v1/managed_clusters/{id} | Update an managedCluster @@ -62,6 +67,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) + + ## CreateAgentRuntime > AgentRuntime CreateAgentRuntime(ctx).AgentRuntime(agentRuntime).Execute() @@ -2310,6 +2388,76 @@ 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) + + ## ListAgentRuntimes > AgentRuntimeList ListAgentRuntimes(ctx).Page(page).Size(size).Search(search).OrderBy(orderBy).Fields(fields).Execute() @@ -2382,6 +2530,86 @@ Name | Type | Description | Notes [[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) + + ## ListGatewayServiceAccounts > OpenShellGatewayServiceAccountList ListGatewayServiceAccounts(ctx, gatewayId).Page(page).Size(size).Status(status).Search(search).Sort(sort).Order(order).Execute() @@ -3248,6 +3476,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() @@ -3319,6 +3616,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) + + ## UpdateAgentRuntime > AgentRuntime UpdateAgentRuntime(ctx, id).AgentRuntimePatchRequest(agentRuntimePatchRequest).Execute() diff --git a/components/api-server/pkg/api/openapi/docs/Gateway.md b/components/api-server/pkg/api/openapi/docs/Gateway.md index 53a0ffc29..d0cb2543c 100644 --- a/components/api-server/pkg/api/openapi/docs/Gateway.md +++ b/components/api-server/pkg/api/openapi/docs/Gateway.md @@ -31,6 +31,8 @@ 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] +**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 @@ -711,6 +713,56 @@ 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. + +### 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/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..7119014ae --- /dev/null +++ b/components/api-server/pkg/api/openapi/docs/GatewayAccessGrantResponse.md @@ -0,0 +1,207 @@ +# 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) | | +**GrantedAt** | **time.Time** | | + +## Methods + +### NewGatewayAccessGrantResponse + +`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, +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. + + +### 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..f445a725a --- /dev/null +++ b/components/api-server/pkg/api/openapi/docs/GatewayAccessListItem.md @@ -0,0 +1,207 @@ +# 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) | | +**GrantedAt** | **time.Time** | | + +## Methods + +### NewGatewayAccessListItem + +`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, +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. + + +### 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.go b/components/api-server/pkg/api/openapi/model_gateway.go index f7aa16de9..6827f8e67 100644 --- a/components/api-server/pkg/api/openapi/model_gateway.go +++ b/components/api-server/pkg/api/openapi/model_gateway.go @@ -64,6 +64,10 @@ 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"` + // 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 @@ -928,6 +932,70 @@ 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 +} + +// 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 { @@ -1013,6 +1081,12 @@ 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 + } + if !IsNil(o.CanEdit) { + toSerialize["can_edit"] = o.CanEdit + } return toSerialize, nil } 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..944dfafd4 --- /dev/null +++ b/components/api-server/pkg/api/openapi/model_gateway_access_grant_response.go @@ -0,0 +1,363 @@ +/* +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"` + 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, grantedAt time.Time) *GatewayAccessGrantResponse { + this := GatewayAccessGrantResponse{} + this.RoleBindingId = roleBindingId + this.UserId = userId + this.Username = username + this.Role = role + 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 +} + +// 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["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", + "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..ad48413e7 --- /dev/null +++ b/components/api-server/pkg/api/openapi/model_gateway_access_list_item.go @@ -0,0 +1,363 @@ +/* +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"` + 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, grantedAt time.Time) *GatewayAccessListItem { + this := GatewayAccessListItem{} + this.RoleBindingId = roleBindingId + this.UserId = userId + this.Username = username + this.Role = role + 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 +} + +// 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["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", + "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/api/openapi_embed_test.go b/components/api-server/pkg/api/openapi_embed_test.go index 4a43be33c..f3ff0c95a 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 != 55 { - t.Fatalf("embedded operation count = %d, want 55", operationCount) + if operationCount != 60 { + t.Fatalf("embedded operation count = %d, want 60", operationCount) } expectedDeletes := map[string]string{ diff --git a/components/api-server/pkg/rbac/authorization.go b/components/api-server/pkg/rbac/authorization.go index 86999769a..bd64ab5ed 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 } @@ -421,6 +442,24 @@ 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) +} + +// 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 @@ -433,6 +472,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 @@ -441,3 +486,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/pkg/rbac/authorization_test.go b/components/api-server/pkg/rbac/authorization_test.go index e718296c2..84aebb33b 100644 --- a/components/api-server/pkg/rbac/authorization_test.go +++ b/components/api-server/pkg/rbac/authorization_test.go @@ -119,6 +119,117 @@ 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) + } + }) + } +} + +// 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/gatewayAccess/dao.go b/components/api-server/plugins/gatewayAccess/dao.go new file mode 100644 index 000000000..a2f9b4c5c --- /dev/null +++ b/components/api-server/plugins/gatewayAccess/dao.go @@ -0,0 +1,129 @@ +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 +} + +// 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..296dc1549 --- /dev/null +++ b/components/api-server/plugins/gatewayAccess/integration_test.go @@ -0,0 +1,462 @@ +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_EnrichedTiers(t *testing.T) { + test.RegisterIntegration(t) + defer gatewayAccess.SetDirectoryResolver(nil) + gw := "gw-list-enriched" + + 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, owner, gatewayAccess.ListOptions{Page: 1, Size: 50}) + Expect(aerr).To(BeNil()) + Expect(total).To(Equal(int64(5))) + Expect(items).To(HaveLen(5)) + + tiers := map[string]int{} + for _, it := range items { + tiers[it.Role]++ + } + 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)) +} + +// 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) + 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 +} + +// 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/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..5a1ea90c9 --- /dev/null +++ b/components/api-server/plugins/gatewayAccess/presenter.go @@ -0,0 +1,53 @@ +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"` + 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..372644408 --- /dev/null +++ b/components/api-server/plugins/gatewayAccess/service.go @@ -0,0 +1,416 @@ +package gatewayAccess + +import ( + "context" + "errors" + "fmt" + "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" + "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 + 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{} + +// 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 { + continue + } + t := roleNameToTier(b.RoleName) + if t != "" && tierRank[t] > tierRank[best] { + best = t + } + } + 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) { + rows, total, err := s.dao.list(ctx, gatewayID, opts) + if err != nil { + return nil, 0, Capabilities{}, serverError("unable to list gateway access") + } + items := make([]GrantItem, 0, len(rows)) + for _, r := range rows { + items = append(items, grantItemFromRow(r)) + } + tier, aerr := s.callerTier(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 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 { + 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.callerTier(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.callerTier(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") + } + return grantItemFromRow(row), nil +} + +func grantItemFromRow(r accessRow) GrantItem { + return GrantItem{ + RoleBindingID: r.RoleBindingID, + UserID: r.UserID, + Username: r.Username, + Name: r.Name, + Email: r.Email, + Role: roleNameToTier(r.RoleName), + 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/gateways/handler.go b/components/api-server/plugins/gateways/handler.go index 2f6b2d439..02d8c1f90 100644 --- a/components/api-server/plugins/gateways/handler.go +++ b/components/api-server/plugins/gateways/handler.go @@ -39,11 +39,56 @@ type gatewayHandler struct { ownerBinding OwnerBindingCreator visibilityFilter GatewayVisibilityFilter ownerLookup GatewayOwnerLookup + bindingLookup rbac.RoleBindingLookup clusters RegisteredClusterLookup + enforceRBAC bool placement PlacementResolver availability PlacementAvailabilityResolver } +// 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) +} + +// 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) +} + func (h gatewayHandler) GetPlacementAvailability(w http.ResponseWriter, r *http.Request) { if h.availability == nil { handlers.HandleGet(w, r, &handlers.HandlerConfig{Action: func() (interface{}, *errors.ServiceError) { @@ -72,14 +117,16 @@ func validateGatewayPhaseValue(phase *string) *errors.ServiceError { return nil } -func NewGatewayHandler(gateway GatewayService, generic services.GenericService, ownerBinding OwnerBindingCreator, visibilityFilter GatewayVisibilityFilter, ownerLookup GatewayOwnerLookup, clusters RegisteredClusterLookup, placement PlacementResolver, availability PlacementAvailabilityResolver) *gatewayHandler { +func NewGatewayHandler(gateway GatewayService, generic services.GenericService, ownerBinding OwnerBindingCreator, visibilityFilter GatewayVisibilityFilter, ownerLookup GatewayOwnerLookup, bindingLookup rbac.RoleBindingLookup, clusters RegisteredClusterLookup, enforceRBAC bool, placement PlacementResolver, availability PlacementAvailabilityResolver) *gatewayHandler { h := &gatewayHandler{ gateway: gateway, generic: generic, ownerBinding: ownerBinding, visibilityFilter: visibilityFilter, ownerLookup: ownerLookup, + bindingLookup: bindingLookup, clusters: clusters, + enforceRBAC: enforceRBAC, placement: placement, availability: availability, } @@ -124,7 +171,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 and edit what they just created. + return PresentGateway(gatewayModel, "", true, true), nil }, ErrorHandler: handlers.HandleError, } @@ -217,7 +266,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), h.callerCanEdit(bindings, gatewayModel.ID)), nil }, ErrorHandler: handlers.HandleError, } @@ -288,8 +338,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), h.callerCanEdit(bindings, gateway.ID)) gatewayList.Items = append(gatewayList.Items, converted) } if listArgs.Fields != nil { @@ -331,7 +382,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), h.callerCanEdit(bindings, gateway.ID)), nil }, } diff --git a/components/api-server/plugins/gateways/plugin.go b/components/api-server/plugins/gateways/plugin.go index 600224c31..971eb92f6 100644 --- a/components/api-server/plugins/gateways/plugin.go +++ b/components/api-server/plugins/gateways/plugin.go @@ -255,6 +255,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) @@ -266,9 +267,11 @@ func init() { return ids, nil }) ownerLookup = rbService + bindingLookup = rbService } + enforceRBAC := os.Getenv("RBAC_ENFORCE") == "true" placement := newRegisteredPlacementService(envServices) - gatewayHandler := NewGatewayHandler(Service(envServices), listService(envServices), ownerBinding, visibilityFilter, ownerLookup, registeredClusterLookup(envServices), placement.Resolve, placement.Availability) + gatewayHandler := NewGatewayHandler(Service(envServices), listService(envServices), ownerBinding, visibilityFilter, ownerLookup, bindingLookup, registeredClusterLookup(envServices), enforceRBAC, placement.Resolve, placement.Availability) 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 ad1b039b0..bf703ae79 100644 --- a/components/api-server/plugins/gateways/presenter.go +++ b/components/api-server/plugins/gateways/presenter.go @@ -33,9 +33,11 @@ func ConvertGateway(gateway openapi.GatewayCreateRequest) *Gateway { return c } -func PresentGateway(gateway *Gateway, createdBy string) 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/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/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 5ff2782da..1111d79f3 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) @@ -208,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 } @@ -251,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 } @@ -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 { @@ -398,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) } diff --git a/components/api-server/plugins/roles/migration.go b/components/api-server/plugins/roles/migration.go index 324d114c9..bf70f107f 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: "2026100600000004", + 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/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/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/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/list/gateways/cmd.go b/components/cli/cmd/hsctl/list/gateways/cmd.go index 293fce53d..9e6d6ece3 100644 --- a/components/cli/cmd/hsctl/list/gateways/cmd.go +++ b/components/cli/cmd/hsctl/list/gateways/cmd.go @@ -38,7 +38,7 @@ func init() { fs := Cmd.Flags() arguments.AddParameterFlag(fs, &args.parameter) arguments.AddNoHeadersFlag(fs, &args.noHeaders) - arguments.AddColumnsFlag(fs, &args.columns, "id, active_sandbox_count, cluster_id, console_address, created_by, created_at") + arguments.AddColumnsFlag(fs, &args.columns, "id, active_sandbox_count, can_delete, can_edit, cluster_id, created_at") arguments.AddOutputFlag(fs, &args.outputFmt) fs.StringVar(&args.search, "search", "", "Search filter expression.") fs.StringVar(&args.orderBy, "order-by", "", "Order by expression.") diff --git a/components/cli/cmd/hsctl/main.go b/components/cli/cmd/hsctl/main.go index 507f64822..7017c7570 100644 --- a/components/cli/cmd/hsctl/main.go +++ b/components/cli/cmd/hsctl/main.go @@ -8,15 +8,20 @@ import ( "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/apply" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/create" + creategatewayaccess "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/create/gatewayAccess" createserviceaccount "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/create/serviceAccount" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/delete" + deletegatewayaccess "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/delete/gatewayAccess" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/extgroup" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/get" getserviceaccount "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/get/serviceAccount" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/list" + listgatewayaccess "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/list/gatewayAccess" listserviceaccounts "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/list/serviceAccounts" "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/revoke" tuicmd "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/tui" + "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/update" + updategatewayaccess "github.com/openshift-online/hypershell/components/cli/cmd/hsctl/update/gatewayAccess" ) var root = &cobra.Command{ @@ -33,6 +38,13 @@ func init() { get.Cmd.AddCommand(getserviceaccount.Cmd) list.Cmd.AddCommand(listserviceaccounts.Cmd) + // Gateway access is a scoped sub-collection (/gateways/{id}/access), like + // service accounts, so its commands are hand-maintained too. + create.Cmd.AddCommand(creategatewayaccess.Cmd) + list.Cmd.AddCommand(listgatewayaccess.Cmd) + update.Cmd.AddCommand(updategatewayaccess.Cmd) + delete.Cmd.AddCommand(deletegatewayaccess.Cmd) + // Split the help of each parent into core and extension sections. Every command, // generated or hand-written, must be registered before this. extgroup.Apply(create.Cmd, delete.Cmd, get.Cmd, list.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/data/generated/tui/descriptor.json b/components/cli/data/generated/tui/descriptor.json index 091ec418a..eaa7a85a2 100644 --- a/components/cli/data/generated/tui/descriptor.json +++ b/components/cli/data/generated/tui/descriptor.json @@ -604,6 +604,115 @@ "getOperationId": "getGatewayPlacementAvailability", "source": {} }, + { + "id": "collection:/api/hypershell/v1/gateways/{gateway_id}/access/directory:#/schemas/GatewayDirectoryUser", + "kind": "collection", + "schemaRef": "#/schemas/GatewayDirectoryUser", + "label": "GatewayDirectoryUser", + "defaultSort": "name", + "columns": [ + { + "property": "name", + "label": "NAME", + "priority": 0, + "type": "string" + }, + { + "property": "email", + "label": "EMAIL", + "priority": 0, + "type": "string" + }, + { + "property": "subject", + "label": "SUBJECT", + "priority": 0, + "type": "string" + }, + { + "property": "username", + "label": "USERNAME", + "priority": 0, + "type": "string" + } + ], + "scopeParameters": [ + "gateway_id" + ], + "operationIds": [ + "searchGatewayDirectory" + ], + "capabilities": [ + "list" + ], + "listOperationId": "searchGatewayDirectory", + "source": {} + }, + { + "id": "collection:/api/hypershell/v1/gateways/{gateway_id}/access:#/schemas/GatewayAccessListItem", + "kind": "collection", + "schemaRef": "#/schemas/GatewayAccessListItem", + "label": "GatewayAccessListItem", + "defaultSort": "name", + "columns": [ + { + "property": "name", + "label": "NAME", + "priority": 0, + "type": "string" + }, + { + "property": "email", + "label": "EMAIL", + "priority": 0, + "type": "string" + }, + { + "property": "granted_at", + "label": "GRANTED AT", + "priority": 0, + "type": "string", + "format": "date-time" + }, + { + "property": "role", + "label": "ROLE", + "priority": 0, + "type": "string" + }, + { + "property": "role_binding_id", + "label": "ROLE BINDING ID", + "priority": 0, + "type": "string" + }, + { + "property": "user_id", + "label": "USER ID", + "priority": 0, + "type": "string" + }, + { + "property": "username", + "label": "USERNAME", + "priority": 0, + "type": "string" + } + ], + "scopeParameters": [ + "gateway_id" + ], + "operationIds": [ + "grantGatewayAccess", + "listGatewayAccess" + ], + "capabilities": [ + "create", + "list" + ], + "listOperationId": "listGatewayAccess", + "source": {} + }, { "id": "collection:/api/hypershell/v1/gateways/{gateway_id}/service_accounts/{service_account_id}/revoke:#/schemas/OpenShellGatewayServiceAccountListItem", "kind": "collection", @@ -857,6 +966,18 @@ "type": "integer", "format": "int32" }, + { + "property": "can_delete", + "label": "CAN DELETE", + "priority": 0, + "type": "boolean" + }, + { + "property": "can_edit", + "label": "CAN EDIT", + "priority": 0, + "type": "boolean" + }, { "property": "cluster_id", "label": "CLUSTER ID", @@ -1947,6 +2068,70 @@ "getOperationId": "getSecretSource", "source": {} }, + { + "id": "item:/api/hypershell/v1/gateways/{gateway_id}/access/{user_id}:#/schemas/GatewayAccessGrantResponse", + "kind": "item", + "schemaRef": "#/schemas/GatewayAccessGrantResponse", + "label": "GatewayAccessGrantResponse", + "defaultSort": "name", + "columns": [ + { + "property": "name", + "label": "NAME", + "priority": 0, + "type": "string" + }, + { + "property": "email", + "label": "EMAIL", + "priority": 0, + "type": "string" + }, + { + "property": "granted_at", + "label": "GRANTED AT", + "priority": 0, + "type": "string", + "format": "date-time" + }, + { + "property": "role", + "label": "ROLE", + "priority": 0, + "type": "string" + }, + { + "property": "role_binding_id", + "label": "ROLE BINDING ID", + "priority": 0, + "type": "string" + }, + { + "property": "user_id", + "label": "USER ID", + "priority": 0, + "type": "string" + }, + { + "property": "username", + "label": "USERNAME", + "priority": 0, + "type": "string" + } + ], + "scopeParameters": [ + "gateway_id" + ], + "operationIds": [ + "changeGatewayAccessRole", + "revokeGatewayAccess" + ], + "capabilities": [ + "delete", + "update" + ], + "source": {} + }, { "id": "item:/api/hypershell/v1/gateways/{gateway_id}/service_accounts/{service_account_id}:#/schemas/OpenShellGatewayServiceAccountListItem", "kind": "item", @@ -2085,6 +2270,18 @@ "type": "integer", "format": "int32" }, + { + "property": "can_delete", + "label": "CAN DELETE", + "priority": 0, + "type": "boolean" + }, + { + "property": "can_edit", + "label": "CAN EDIT", + "priority": 0, + "type": "boolean" + }, { "property": "cluster_id", "label": "CLUSTER ID", @@ -2570,6 +2767,89 @@ } ], "operations": [ + { + "id": "changeGatewayAccessRole", + "method": "PATCH", + "pathParts": [ + { + "literal": "/api/hypershell/v1/gateways/" + }, + { + "parameter": "gateway_id" + }, + { + "literal": "/access/" + }, + { + "parameter": "user_id" + } + ], + "parameters": [ + { + "name": "gateway_id", + "in": "path", + "required": true, + "style": "simple", + "explode": false, + "type": "string", + "description": "Selected Gateway ID" + }, + { + "name": "user_id", + "in": "path", + "required": true, + "style": "simple", + "explode": false, + "type": "string", + "description": "HyperShell User ID" + } + ], + "requestBody": { + "required": true, + "contentType": "application/json", + "fields": [ + { + "name": "role", + "type": "string", + "required": true, + "enum": [ + "owner", + "admin", + "user" + ] + } + ] + }, + "response": { + "contentType": "application/json" + }, + "successStatuses": [ + "200" + ], + "servers": [ + { + "url": "http://localhost:8000" + }, + { + "url": "/" + } + ], + "security": { + "none": false, + "requirements": [ + { + "schemes": [ + "Bearer" + ] + } + ] + }, + "capabilities": [ + "update" + ], + "summary": "Change a user's access role on a gateway", + "presentation": {} + }, { "id": "createAgentRuntime", "method": "POST", @@ -4880,22 +5160,104 @@ "presentation": {} }, { - "id": "listAgentRuntimes", - "method": "GET", + "id": "grantGatewayAccess", + "method": "POST", "pathParts": [ { - "literal": "/api/hypershell/ext/agent_runtimes" + "literal": "/api/hypershell/v1/gateways/" + }, + { + "parameter": "gateway_id" + }, + { + "literal": "/access" } ], "parameters": [ { - "name": "page", - "in": "query", - "required": false, - "style": "form", - "explode": true, - "type": "integer", - "description": "Page number of record list when record list exceeds specified page size", + "name": "gateway_id", + "in": "path", + "required": true, + "style": "simple", + "explode": false, + "type": "string", + "description": "Selected Gateway ID" + } + ], + "requestBody": { + "required": true, + "contentType": "application/json", + "fields": [ + { + "name": "role", + "type": "string", + "required": true, + "enum": [ + "owner", + "admin", + "user" + ] + }, + { + "name": "subject", + "type": "string", + "required": false, + "description": "Optional Keycloak subject reference returned by the directory search" + }, + { + "name": "username", + "type": "string", + "required": true + } + ] + }, + "response": { + "contentType": "application/json" + }, + "successStatuses": [ + "201" + ], + "servers": [ + { + "url": "http://localhost:8000" + }, + { + "url": "/" + } + ], + "security": { + "none": false, + "requirements": [ + { + "schemes": [ + "Bearer" + ] + } + ] + }, + "capabilities": [ + "create" + ], + "summary": "Grant a user access to a gateway", + "presentation": {} + }, + { + "id": "listAgentRuntimes", + "method": "GET", + "pathParts": [ + { + "literal": "/api/hypershell/ext/agent_runtimes" + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "style": "form", + "explode": true, + "type": "integer", + "description": "Page number of record list when record list exceeds specified page size", "default": 1 }, { @@ -4967,6 +5329,131 @@ "summary": "Returns a list of agent runtimes", "presentation": {} }, + { + "id": "listGatewayAccess", + "method": "GET", + "pathParts": [ + { + "literal": "/api/hypershell/v1/gateways/" + }, + { + "parameter": "gateway_id" + }, + { + "literal": "/access" + } + ], + "parameters": [ + { + "name": "gateway_id", + "in": "path", + "required": true, + "style": "simple", + "explode": false, + "type": "string", + "description": "Selected Gateway ID" + }, + { + "name": "page", + "in": "query", + "required": false, + "style": "form", + "explode": true, + "type": "integer", + "default": 1 + }, + { + "name": "size", + "in": "query", + "required": false, + "style": "form", + "explode": true, + "type": "integer", + "default": 20 + }, + { + "name": "role", + "in": "query", + "required": false, + "style": "form", + "explode": true, + "type": "string", + "description": "Filter by console role tier", + "enum": [ + "owner", + "admin", + "user" + ] + }, + { + "name": "search", + "in": "query", + "required": false, + "style": "form", + "explode": true, + "type": "string", + "description": "Case-insensitive literal substring over username and name" + }, + { + "name": "sort", + "in": "query", + "required": false, + "style": "form", + "explode": true, + "type": "string", + "enum": [ + "username", + "name", + "role", + "granted_at" + ], + "default": "granted_at" + }, + { + "name": "order", + "in": "query", + "required": false, + "style": "form", + "explode": true, + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + } + ], + "response": { + "contentType": "application/json", + "itemsPointer": "/items" + }, + "successStatuses": [ + "200" + ], + "servers": [ + { + "url": "http://localhost:8000" + }, + { + "url": "/" + } + ], + "security": { + "none": false, + "requirements": [ + { + "schemes": [ + "Bearer" + ] + } + ] + }, + "capabilities": [ + "list" + ], + "summary": "List access grants on a gateway", + "presentation": {} + }, { "id": "listGatewayServiceAccounts", "method": "GET", @@ -6055,6 +6542,77 @@ "summary": "Self-register a control plane as a managed cluster", "presentation": {} }, + { + "id": "revokeGatewayAccess", + "method": "DELETE", + "pathParts": [ + { + "literal": "/api/hypershell/v1/gateways/" + }, + { + "parameter": "gateway_id" + }, + { + "literal": "/access/" + }, + { + "parameter": "user_id" + } + ], + "parameters": [ + { + "name": "gateway_id", + "in": "path", + "required": true, + "style": "simple", + "explode": false, + "type": "string", + "description": "Selected Gateway ID" + }, + { + "name": "user_id", + "in": "path", + "required": true, + "style": "simple", + "explode": false, + "type": "string", + "description": "HyperShell User ID" + } + ], + "response": {}, + "successStatuses": [ + "204" + ], + "servers": [ + { + "url": "http://localhost:8000" + }, + { + "url": "/" + } + ], + "security": { + "none": false, + "requirements": [ + { + "schemes": [ + "Bearer" + ] + } + ] + }, + "capabilities": [ + "delete" + ], + "summary": "Revoke a user's access to a gateway", + "presentation": { + "confirmation": { + "title": "Delete", + "message": "Revoke a user's access to a gateway?", + "destructive": true + } + } + }, { "id": "revokeGatewayServiceAccount", "method": "POST", @@ -6126,6 +6684,71 @@ "summary": "Permanently revoke an OpenShell gateway service account", "presentation": {} }, + { + "id": "searchGatewayDirectory", + "method": "GET", + "pathParts": [ + { + "literal": "/api/hypershell/v1/gateways/" + }, + { + "parameter": "gateway_id" + }, + { + "literal": "/access/directory" + } + ], + "parameters": [ + { + "name": "gateway_id", + "in": "path", + "required": true, + "style": "simple", + "explode": false, + "type": "string", + "description": "Selected Gateway ID" + }, + { + "name": "search", + "in": "query", + "required": false, + "style": "form", + "explode": true, + "type": "string", + "description": "Case-insensitive literal substring over username and name" + } + ], + "response": { + "contentType": "application/json", + "itemsPointer": "/items" + }, + "successStatuses": [ + "200" + ], + "servers": [ + { + "url": "http://localhost:8000" + }, + { + "url": "/" + } + ], + "security": { + "none": false, + "requirements": [ + { + "schemes": [ + "Bearer" + ] + } + ] + }, + "capabilities": [ + "list" + ], + "summary": "Search the Keycloak realm directory for candidate users", + "presentation": {} + }, { "id": "updateAgentRuntime", "method": "PATCH", @@ -6959,6 +7582,40 @@ ], "navigable": true }, + { + "id": "item:/api/hypershell/v1/gateways/{id}:#/schemas/Gateway-\u003ecollection:/api/hypershell/v1/gateways/{gateway_id}/access/directory:#/schemas/GatewayDirectoryUser:contains", + "name": "contains", + "sourceViewId": "item:/api/hypershell/v1/gateways/{id}:#/schemas/Gateway", + "targetViewId": "collection:/api/hypershell/v1/gateways/{gateway_id}/access/directory:#/schemas/GatewayDirectoryUser", + "sourceOperationId": "getGateway", + "targetOperationId": "searchGatewayDirectory", + "provenance": "inferred-path", + "bindings": [ + { + "target": "gateway_id", + "sourceKind": "row-property", + "source": "id" + } + ], + "navigable": true + }, + { + "id": "item:/api/hypershell/v1/gateways/{id}:#/schemas/Gateway-\u003ecollection:/api/hypershell/v1/gateways/{gateway_id}/access:#/schemas/GatewayAccessListItem:contains", + "name": "contains", + "sourceViewId": "item:/api/hypershell/v1/gateways/{id}:#/schemas/Gateway", + "targetViewId": "collection:/api/hypershell/v1/gateways/{gateway_id}/access:#/schemas/GatewayAccessListItem", + "sourceOperationId": "getGateway", + "targetOperationId": "listGatewayAccess", + "provenance": "inferred-path", + "bindings": [ + { + "target": "gateway_id", + "sourceKind": "row-property", + "source": "id" + } + ], + "navigable": true + }, { "id": "item:/api/hypershell/v1/gateways/{id}:#/schemas/Gateway-\u003ecollection:/api/hypershell/v1/gateways/{gateway_id}/service_accounts:#/schemas/OpenShellGatewayServiceAccountListItem:contains", "name": "contains", 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 cfd626b69..e8837c809 100644 --- a/components/control-plane/cmd/hypershell-controller/main.go +++ b/components/control-plane/cmd/hypershell-controller/main.go @@ -17,9 +17,11 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" + 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" "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" @@ -277,8 +279,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 91ff87d0c..853090f35 100644 --- a/components/control-plane/internal/config/config.go +++ b/components/control-plane/internal/config/config.go @@ -66,6 +66,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 @@ -122,6 +126,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..6c072bf41 --- /dev/null +++ b/components/control-plane/internal/directory/directory.go @@ -0,0 +1,166 @@ +// 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 +) + +// 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) +} + +// 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. +// 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 { + 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..1a71160ae --- /dev/null +++ b/components/control-plane/internal/directory/directory_test.go @@ -0,0 +1,104 @@ +package directory + +import ( + "context" + "errors" + "testing" + "time" + + "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") + } +} + +// 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")} + 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/agent_runtime_api.go b/components/sdk-go/client/agent_runtime_api.go index 5ef17dbbb..309636b24 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package client diff --git a/components/sdk-go/client/client.go b/components/sdk-go/client/client.go index 60f9c16dd..e3c391c82 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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..026cc020c --- /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: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 + +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("/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("/api/hypershell/v1/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("/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 + } + 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("/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("/api/hypershell/v1/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 f78e2ff11..3847471d2 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package client diff --git a/components/sdk-go/client/inference_route_api.go b/components/sdk-go/client/inference_route_api.go index 7d770df5a..7f0423c80 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package client diff --git a/components/sdk-go/client/iterator.go b/components/sdk-go/client/iterator.go index a0e3ca9ef..8e85bad20 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package client diff --git a/components/sdk-go/client/managed_cluster_api.go b/components/sdk-go/client/managed_cluster_api.go index 45869c2f6..bb759402a 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 481f50e84..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 @@ -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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package client @@ -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-go/client/provider_binding_api.go b/components/sdk-go/client/provider_binding_api.go index 55fbd5cb2..32aea1b8c 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package client diff --git a/components/sdk-go/client/provider_spec_api.go b/components/sdk-go/client/provider_spec_api.go index ba9c3b810..8d599a479 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package client diff --git a/components/sdk-go/client/role_api.go b/components/sdk-go/client/role_api.go index ebfef98dd..95c421570 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package client diff --git a/components/sdk-go/client/role_binding_api.go b/components/sdk-go/client/role_binding_api.go index 293336a0b..517a6886b 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package client diff --git a/components/sdk-go/client/sandbox_template_api.go b/components/sdk-go/client/sandbox_template_api.go index ddebeabdf..0b259d13e 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package client diff --git a/components/sdk-go/client/secret_source_api.go b/components/sdk-go/client/secret_source_api.go index a1b36adef..af9eeb488 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package client diff --git a/components/sdk-go/client/user_api.go b/components/sdk-go/client/user_api.go index 89090e27e..43e0b29a9 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package client diff --git a/components/sdk-go/types/agent_runtime.go b/components/sdk-go/types/agent_runtime.go index eec996a6e..4463604cd 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package types diff --git a/components/sdk-go/types/base.go b/components/sdk-go/types/base.go index 4eb1af4ef..960e5f79d 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package types diff --git a/components/sdk-go/types/gateway.go b/components/sdk-go/types/gateway.go index 1f5f44841..9b9424698 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package types @@ -29,6 +29,8 @@ type Gateway struct { ObjectReference 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 new file mode 100644 index 000000000..e5d517434 --- /dev/null +++ b/components/sdk-go/types/gateway_access.go @@ -0,0 +1,129 @@ +// Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. +// Source: components/api-server/openapi/openapi.yaml +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 + +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"` + 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"` + 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/inference_route.go b/components/sdk-go/types/inference_route.go index 9b44c53f1..b564e748c 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package types diff --git a/components/sdk-go/types/list_options.go b/components/sdk-go/types/list_options.go index e1cfc1347..fcfab4ed1 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package types diff --git a/components/sdk-go/types/managed_cluster.go b/components/sdk-go/types/managed_cluster.go index e51e2e2eb..0ebba4c50 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 c25321be9..bc6d65da7 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package types diff --git a/components/sdk-go/types/provider_binding.go b/components/sdk-go/types/provider_binding.go index a2ce63a9f..04c1187db 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package types diff --git a/components/sdk-go/types/provider_spec.go b/components/sdk-go/types/provider_spec.go index 802f844a0..5599a0162 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package types diff --git a/components/sdk-go/types/role.go b/components/sdk-go/types/role.go index 2eb79bfc0..9b30a6e0f 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package types diff --git a/components/sdk-go/types/role_binding.go b/components/sdk-go/types/role_binding.go index 19516384b..071b52c12 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package types diff --git a/components/sdk-go/types/sandbox_template.go b/components/sdk-go/types/sandbox_template.go index fc1e0a92d..58a078094 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package types diff --git a/components/sdk-go/types/secret_source.go b/components/sdk-go/types/secret_source.go index c81f80a27..26caed118 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package types diff --git a/components/sdk-go/types/user.go b/components/sdk-go/types/user.go index 9d083c20c..d10010b6a 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 package types diff --git a/components/sdk-typescript/src/agent_runtime.ts b/components/sdk-typescript/src/agent_runtime.ts index 76471d473..1310ea87a 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 5d0c6d8d3..8d30842d5 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 248c52f24..2d6fc6a42 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 export type ObjectReference = { id: string; diff --git a/components/sdk-typescript/src/client.ts b/components/sdk-typescript/src/client.ts index f37715bdb..9783e066b 100644 --- a/components/sdk-typescript/src/client.ts +++ b/components/sdk-typescript/src/client.ts @@ -1,10 +1,11 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 import type { SDKClientConfig } from './base.js'; import { AgentRuntimeAPI } from './agent_runtime_api.js'; import { GatewayAPI } from './gateway_api.js'; +import { GatewayAccessAPI } from './gateway_access_api.js'; import { InferenceRouteAPI } from './inference_route_api.js'; import { ManagedClusterAPI } from './managed_cluster_api.js'; import { OpenShellGatewayServiceAccountAPI } from './open_shell_gateway_service_account_api.js'; @@ -22,6 +23,7 @@ export class SDKClient { readonly agentRuntimes: AgentRuntimeAPI; readonly gateways: GatewayAPI; + readonly gatewayAccesses: GatewayAccessAPI; readonly inferenceRoutes: InferenceRouteAPI; readonly managedClusters: ManagedClusterAPI; readonly openShellGatewayServiceAccounts: OpenShellGatewayServiceAccountAPI; @@ -45,6 +47,7 @@ export class SDKClient { this.agentRuntimes = new AgentRuntimeAPI(this.config); this.gateways = new GatewayAPI(this.config); + this.gatewayAccesses = new GatewayAccessAPI(this.config); this.inferenceRoutes = new InferenceRouteAPI(this.config); this.managedClusters = new ManagedClusterAPI(this.config); this.openShellGatewayServiceAccounts = new OpenShellGatewayServiceAccountAPI(this.config); diff --git a/components/sdk-typescript/src/gateway.ts b/components/sdk-typescript/src/gateway.ts index 9976499b5..266cf1d15 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 import type { ObjectReference, ListMeta } from './base.js'; export type GatewayPlacementAvailability = { @@ -24,6 +24,8 @@ export type GatewayPlacementIntent = 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 new file mode 100644 index 000000000..d90723084 --- /dev/null +++ b/components/sdk-typescript/src/gateway_access.ts @@ -0,0 +1,87 @@ +// Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. +// Source: components/api-server/openapi/openapi.yaml +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 + + +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; + 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; + 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..9b2f74a14 --- /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: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 + +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', `/api/hypershell/v1/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 = `/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', `/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', `/api/hypershell/v1/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 = `/api/hypershell/v1/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 c5ef3d068..700c5c8d6 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 349780669..2005dd1a6 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 export { SDKClient } from './client.js'; export type { SDKClientConfig, ListOptions, RequestOptions, ObjectReference, ListMeta, APIError } from './base.js'; @@ -19,6 +19,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 { InferenceRoute, InferenceRouteList, InferenceRouteCreateRequest, InferenceRoutePatchRequest } from './inference_route.js'; export { InferenceRouteBuilder, InferenceRoutePatchBuilder } from './inference_route.js'; export { InferenceRouteAPI } from './inference_route_api.js'; diff --git a/components/sdk-typescript/src/inference_route.ts b/components/sdk-typescript/src/inference_route.ts index f4473b185..60ed946d3 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 53e4b37aa..f5b6f0099 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 be7c25483..d6d11c8f8 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 b3c33139e..b39578220 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 208a58543..ae23d09d8 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 96bec4aac..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 @@ -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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 import type { SDKClientConfig, RequestOptions } from './base.js'; import { sdkFetch } from './base.js'; @@ -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/components/sdk-typescript/src/provider_binding.ts b/components/sdk-typescript/src/provider_binding.ts index a25ca4bbf..32591ed3f 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 efabea287..4f49f821c 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 1fe81f2f5..be39dc644 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 4b588b7b4..fdfd90150 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 2a9882186..f3c48f309 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 e514c4b28..8454c8300 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 f59522d16..f27e1c4ff 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 b34d1677d..d902acddf 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 7a519a122..0f1c1f4d5 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 35bf611ce..72489f6ee 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 13f1bcff3..d93de7548 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 8586227b7..7d9792af1 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 e8cdc1305..3394115cb 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 8c0c428f6..5cae13f29 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: 4baf5c692945496c1ba8a1c195d838e0f028eb1471cf283b226daf69880cdc1d +// Spec SHA256: bdd81ace10ada1fc8d991b81deba36cb2ba1446202ad6da7dc0e8559396bcf68 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 a64bf9c46..89bce920f 100644 --- a/components/web-console/app/adapters/api/gateway-operations.test.ts +++ b/components/web-console/app/adapters/api/gateway-operations.test.ts @@ -32,7 +32,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, @@ -52,6 +60,8 @@ const listRequest = { function gateway(overrides: Partial = {}): Gateway { return { active_sandbox_count: 0, + can_delete: false, + can_edit: false, cluster_id: "", console_address: "", created_at: null, @@ -804,4 +814,147 @@ 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", + 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({ + 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", + 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 48c78ba85..84a72bc7e 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, @@ -22,6 +26,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, @@ -42,7 +49,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; @@ -171,6 +183,12 @@ function toGatewayRecord(gateway: Gateway): GatewayRecord { return { ...(activeSandboxCount !== undefined ? { activeSandboxCount } : {}), + ...(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 } : {}), @@ -199,6 +217,46 @@ 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, + ...(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 { @@ -323,6 +381,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/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 c614812e7..ba6cc9896 100644 --- a/components/web-console/locales/en.json +++ b/components/web-console/locales/en.json @@ -731,6 +731,206 @@ "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": "Name", + "description": "Access table column for the user's display name." + }, + "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.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." + }, + "app.gateway.access.viewerReadOnly": { + "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." @@ -908,7 +1108,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": { @@ -947,6 +1147,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." @@ -1071,6 +1275,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/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..d3e16cede --- /dev/null +++ b/packages/gateway-management-ui/src/access/access-page.test.tsx @@ -0,0 +1,440 @@ +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", + name: "Olivia Owner", + role: "owner", + roleBindingId: "rb-owner", + userId: "u-owner", + username: "owner1", +}; +const secondOwner: GatewayAccessGrantRecord = { + grantedAt: "2026-08-21T12:00:00Z", + name: "Owen Two", + role: "owner", + roleBindingId: "rb-owner-2", + userId: "u-owner-2", + username: "owner2", +}; +const admin: GatewayAccessGrantRecord = { + grantedAt: "2026-08-21T12:00:00Z", + name: "Amy Admin", + role: "admin", + roleBindingId: "rb-admin", + userId: "u-admin", + username: "admin1", +}; +const user: GatewayAccessGrantRecord = { + grantedAt: "2026-08-21T12:00:00Z", + 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(currentUser?: { username?: string }) { + 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, 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(); + }); + + 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.getAttribute("aria-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.getAttribute("aria-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("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)); + 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("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)); + 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..9eb908e39 --- /dev/null +++ b/packages/gateway-management-ui/src/access/access-page.tsx @@ -0,0 +1,405 @@ +import { + Alert, + Button, + Content, + Flex, + FlexItem, + MenuToggle, + Select, + SelectList, + SelectOption, + Spinner, + 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"; +import { GrantWorkspaceAccessAction } from "./workspace-access-command"; + +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), + 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 ? ( + + + + + {grant.role === "user" ? ( + + + + ) : null} + + ) : ( + 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..e27d272b8 --- /dev/null +++ b/packages/gateway-management-ui/src/access/access-row-actions.tsx @@ -0,0 +1,316 @@ +import { + Alert, + Button, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalVariant, + Select, + SelectList, + SelectOption, + MenuToggle, + type MenuToggleElement, + Tooltip, +} 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 { 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), + onError: (error) => { + onActionError(actionErrorMessage(intl.formatMessage, error)); + }, + onSuccess: async () => { + onActionError(null); + await queryClient.invalidateQueries({ + queryKey: accessListQueryRoot(gatewayId), + }); + }, + retry: false, + }); + + 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} + variant="plainText" + > + {label} + + ); + + return ( + <> + + {pendingSelfRole ? ( + { + setPendingSelfRole(null); + } + } + variant={ModalVariant.small} + > + + +

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

+ {mutation.isError ? ( + + ) : null} +
+ + + + +
+ ) : null} + + ); +} + +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)}`; + + // 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 ? ( + + + +

+ {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..e9a6fd800 --- /dev/null +++ b/packages/gateway-management-ui/src/access/add-users-dialog.tsx @@ -0,0 +1,361 @@ +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"; +import { WorkspaceAccessCommand } from "./workspace-access-command"; + +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/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 f99f66ccf..e6cb5f89d 100644 --- a/packages/gateway-management-ui/src/application/gateway-operations.test.ts +++ b/packages/gateway-management-ui/src/application/gateway-operations.test.ts @@ -33,7 +33,18 @@ function setup() { total: 1, }); const renameGateway = vi.fn().mockResolvedValue(gateway); + const grant = { + grantedAt: "2026-08-06T18:00:00.000Z", + 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({ @@ -54,12 +65,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, @@ -82,7 +102,15 @@ function setup() { }, }); - return { controlPlane, listGateways, operations, received, renameGateway }; + return { + changeGatewayAccessRole, + controlPlane, + grantGatewayAccess, + listGateways, + operations, + received, + renameGateway, + }; } describe("gateway application operations", () => { @@ -171,6 +199,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) => { @@ -234,6 +296,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 1fe538077..cac55245f 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( @@ -221,6 +225,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( @@ -253,6 +265,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 d0480b47e..405c9850b 100644 --- a/packages/gateway-management-ui/src/application/gateway-probes.ts +++ b/packages/gateway-management-ui/src/application/gateway-probes.ts @@ -6,6 +6,7 @@ import type { import type { GatewayFailureKind } from "./gateway-types"; export type GatewayAction = + | "change-access-role" | "create-service-account" | "delete-service-account" | "find-placements" @@ -13,13 +14,17 @@ export type GatewayAction = | "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 48d389ae6..3897678a8 100644 --- a/packages/gateway-management-ui/src/application/gateway-types.ts +++ b/packages/gateway-management-ui/src/application/gateway-types.ts @@ -9,6 +9,8 @@ export interface ProvisioningCondition { export interface GatewayRecord { activeSandboxCount?: number; + canDelete?: boolean; + canEdit?: boolean; clusterId: string; consoleUrl?: string; createdAt?: string; @@ -213,6 +215,68 @@ 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"; @@ -240,11 +304,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, @@ -305,11 +395,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/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/gateways/gateway-connections.ts b/packages/gateway-management-ui/src/gateways/gateway-connections.ts index 75c5b2f26..8df8ef936 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-connections.ts +++ b/packages/gateway-management-ui/src/gateways/gateway-connections.ts @@ -2,6 +2,8 @@ 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-create.test.tsx b/packages/gateway-management-ui/src/gateways/gateway-create.test.tsx index e2cbfaa30..ae9d13a2f 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-create.test.tsx +++ b/packages/gateway-management-ui/src/gateways/gateway-create.test.tsx @@ -21,7 +21,12 @@ const availablePlacement = { } as const; const operations = { + 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-data.ts b/packages/gateway-management-ui/src/gateways/gateway-data.ts index 363625fac..7685f51a1 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-data.ts +++ b/packages/gateway-management-ui/src/gateways/gateway-data.ts @@ -378,6 +378,12 @@ export function toGatewayConnection( ...(typeof gateway.activeSandboxCount === "number" ? { activeSandboxCount: gateway.activeSandboxCount } : {}), + ...(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 35a52f0b8..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,6 +119,11 @@ function GatewayDetailActions({ const [isActionsOpen, setIsActionsOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isRenameOpen, setIsRenameOpen] = useState(false); + // 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 ( <> @@ -188,18 +193,44 @@ function GatewayDetailActions({ > { + if (!canEdit) { + return; + } setIsRenameOpen(true); }} + tooltipProps={ + canEdit + ? undefined + : { + content: intl.formatMessage( + messages.renameGatewayNotPermitted, + ), + } + } > { + 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..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,6 +35,11 @@ export function GatewayRowActions({ const [isRenameOpen, setIsRenameOpen] = useState(false); const [copyResult, setCopyResult] = useState<"error" | "success">(); const connectionCommand = buildGatewayAddCommand(gateway); + // 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) { @@ -95,17 +100,43 @@ export function GatewayRowActions({ ) : null} { + if (!canEdit) { + return; + } setIsRenameOpen(true); }} + tooltipProps={ + canEdit + ? undefined + : { + content: intl.formatMessage( + messages.renameGatewayNotPermitted, + ), + } + } > { + if (!canDelete) { + return; + } setIsDeleteOpen(true); }} + tooltipProps={ + canDelete + ? undefined + : { + content: intl.formatMessage( + messages.deleteGatewayNotPermitted, + ), + } + } > diff --git a/packages/gateway-management-ui/src/index.ts b/packages/gateway-management-ui/src/index.ts index cb4469d5e..a0d955e19 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, @@ -37,8 +45,10 @@ export type { OpenShellGatewayServiceAccountStatus, } from "./application/gateway-types"; export { + defaultGatewayAccessListRequest, defaultOpenShellGatewayServiceAccountListRequest, defaultGatewayListRequest, + gatewayAccessPageSizes, gatewayListPageSizes, GatewayOperationError, openShellGatewayServiceAccountPageSizes, @@ -90,6 +100,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 ab5aa90e4..951f32966 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: { @@ -264,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}?", @@ -650,6 +658,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}", @@ -1191,4 +1205,269 @@ 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: "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.", + }, + 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.", + }, + 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", + 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.", + }, + 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/pages/gateway-pages.test.tsx b/packages/gateway-management-ui/src/pages/gateway-pages.test.tsx index 414acb18b..827026cda 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 00e6eebfc..d2b7afa00 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/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/generate-cli.sh b/scripts/generate-cli.sh index 8075ed3b0..381a46d98 100755 --- a/scripts/generate-cli.sh +++ b/scripts/generate-cli.sh @@ -32,6 +32,12 @@ HAND_MAINTAINED_COMMANDS=( cmd/hsctl/get/serviceAccount/cmd.go cmd/hsctl/list/serviceAccounts/cmd.go cmd/hsctl/delete/serviceAccount/cmd.go + # gatewayAccess is a scoped sub-collection (/gateways/{id}/access), like + # service accounts, so it is hand-maintained rather than generator-emitted. + cmd/hsctl/create/gatewayAccess/cmd.go + cmd/hsctl/list/gatewayAccess/cmd.go + cmd/hsctl/update/gatewayAccess/cmd.go + cmd/hsctl/delete/gatewayAccess/cmd.go ) for kind in agentRuntime sandboxTemplate providerSpec providerBinding inferenceRoute secretSource; do for verb in create get delete; do 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/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}" "$@" diff --git a/scripts/kind/up.sh b/scripts/kind/up.sh index 342bfaf0b..07cbd5a4f 100755 --- a/scripts/kind/up.sh +++ b/scripts/kind/up.sh @@ -1016,6 +1016,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" @@ -1034,6 +1035,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/scripts/sdk-generator/model.go b/scripts/sdk-generator/model.go index 8d945a928..c8e91e45f 100644 --- a/scripts/sdk-generator/model.go +++ b/scripts/sdk-generator/model.go @@ -34,6 +34,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 ef7860747..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 { @@ -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 { @@ -111,19 +134,37 @@ func projectScopedResource(document *ir.Document, collection *ir.ResourceView, a }) } - collectionRelative := relativeAPIPath(collection.Path, apiPrefix) - itemRelative := relativeAPIPath(itemPath, apiPrefix) - return Resource{ + 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), 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, @@ -132,8 +173,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(directoryPath, scopeParameters, nil) + resource.TSDirectoryPath = tsPathExpression(directoryPath, 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 +223,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) } @@ -395,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)") { 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 7fedf711d..c75a1b13f 100644 --- a/skills/RECONCILE.md +++ b/skills/RECONCILE.md @@ -49,9 +49,9 @@ skills/ ## Reconciliation State -**Scoped analysis (2026-09-29)**: `specs/web-console/gateway-provision-placement.spec.md` against `77feccc3` plus the current working tree: 10 Present. GP-W1 made REST creation intent-only and refreshed restored placement metadata; GP-W2 added localized, programmatically associated disabled-network reasons. +**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) -**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) +**Scoped analysis (2026-09-29)**: `specs/web-console/gateway-provision-placement.spec.md` against `77feccc3` plus the current working tree: 10 Present. GP-W1 made REST creation intent-only and refreshed restored placement metadata; GP-W2 added localized, programmatically associated disabled-network reasons. **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`) @@ -123,6 +123,57 @@ 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. + +### Kind Removal: GatewayRelease + GatewayNetwork + +These items track deletion of deprecated kinds across all implementation layers. +Execution order is the reverse of creation: FE -> CLI -> CP (watcher+reconciler) -> BE+gRPC (plugin, handler, presenter, migration) -> SDK (generated types) -> API (OpenAPI routes+schema). +Database tables are dropped via a final migration after confirmed zero rows. + +| # | Kind | Layer | Action | Status | Code Location | Wave | +|---|------|-------|--------|--------|---------------|------| +| RM-1 | GatewayRelease | FE | Remove any UI references (list/detail views, selectors) | Missing | `components/web-console/` | RM-W1 | +| RM-2 | GatewayNetwork | FE | Remove any UI references | Missing | `components/web-console/` | RM-W1 | +| RM-3 | GatewayRelease | CLI | Delete `hsctl create/get/list/delete gatewayRelease` commands | Missing | `components/cli/pkg/tui/rest.go`, `components/cli/` | RM-W2 | +| RM-4 | GatewayNetwork | CLI | Delete `hsctl create/get/list/delete gatewayNetwork` commands | Missing | `components/cli/pkg/tui/rest.go`, `components/cli/` | RM-W2 | +| RM-5 | GatewayRelease | CP | Delete `GatewayReleaseReconciler`, `WatchGatewayReleases`, `gateway_version_selection_test.go` | Missing | `components/control-plane/internal/reconciler/reconciler.go`, `watcher/watcher.go` | RM-W3 | +| RM-6 | GatewayNetwork | CP | Delete `GatewayNetworkReconciler`, `WatchGatewayNetworks`, `gateway_network_test.go` | Missing | `components/control-plane/internal/reconciler/reconciler.go`, `watcher/watcher.go` | RM-W3 | +| RM-7 | GatewayRelease | CP | Remove `release_id` propagation from `GatewayReconciler`; keep `image` path only | Missing | `components/control-plane/internal/reconciler/reconciler.go` | RM-W3 | +| RM-8 | GatewayRelease | BE | Delete `components/api-server/plugins/gatewayReleases/` plugin entirely | Missing | `components/api-server/plugins/gatewayReleases/` | RM-W4 | +| RM-9 | GatewayNetwork | BE | Delete `components/api-server/plugins/gatewayNetworks/` plugin entirely | Missing | `components/api-server/plugins/gatewayNetworks/` | RM-W4 | +| RM-10 | GatewayRelease | gRPC | Delete `gateway_releases.proto`, regenerate stubs, delete `grpc_handler.go`, `grpc_presenter.go`, update `grpc_authorization.go` | Missing | `components/api-server/proto/`, `plugins/gatewayReleases/grpc_*` | RM-W4 | +| RM-11 | GatewayNetwork | gRPC | Delete `gateway_networks.proto`, regenerate stubs, delete `grpc_handler.go`, `grpc_presenter.go`, update `grpc_authorization.go` | Missing | `components/api-server/proto/`, `plugins/gatewayNetworks/grpc_*` | RM-W4 | +| RM-12 | GatewayRelease | API | Remove `/gateway_releases` routes and `GatewayRelease` schema from OpenAPI spec, regenerate | Missing | `components/api-server/openapi/` | RM-W5 | +| RM-13 | GatewayNetwork | API | Remove `/gateway_networks` routes and `GatewayNetwork` schema from OpenAPI spec, regenerate | Missing | `components/api-server/openapi/` | RM-W5 | +| RM-14 | GatewayRelease | SDK | Regenerate Go + TS SDKs (generated files will drop GatewayRelease types automatically) | Missing | `components/sdk-go/`, `components/sdk-typescript/` | RM-W5 | +| RM-15 | GatewayNetwork | SDK | Regenerate Go + TS SDKs | Missing | `components/sdk-go/`, `components/sdk-typescript/` | RM-W5 | +| RM-16 | GatewayRelease | DB | Add migration to drop `gateway_releases` table (after zero-row verification) | Missing | `components/api-server/plugins/gatewayReleases/migration.go` | RM-W6 | +| RM-17 | GatewayNetwork | DB | Add migration to drop `gateway_networks` table | Missing | `components/api-server/plugins/gatewayNetworks/migration.go` | RM-W6 | +| RM-18 | Gateway | BE | Remove `release_id` field from Gateway model, handler, and migration (nullable FK to removed table) | Missing | `components/api-server/plugins/gateways/` | RM-W4 | +| RM-19 | Gateway | API | Remove `release_id` from OpenAPI Gateway schema, regenerate | Missing | `components/api-server/openapi/` | RM-W5 | +| RM-20 | GatewayRelease+GatewayNetwork | RBAC | Remove both from `grpc_authorization.go` control-plane identity allow-list | Missing | `components/api-server/pkg/rbac/grpc_authorization.go` | RM-W4 | + ### generated-gateway-config-validation.spec.md (HYPERSHELL-179) | # | Requirement | Status | Gap | Code Location | Wave | diff --git a/specs/platform/data-model.spec.md b/specs/platform/data-model.spec.md index f30a38831..d20bb1dfe 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` (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: @@ -813,6 +813,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: `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/ephemeral-pr-environments.spec.md b/specs/platform/ephemeral-pr-environments.spec.md index 19585cddc..4e280bc8d 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 new file mode 100644 index 000000000..bdccb8b90 --- /dev/null +++ b/specs/platform/gateway-access-management.spec.md @@ -0,0 +1,499 @@ +# 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`, 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`). + +### 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`. + +The three per-gateway roles form a strict hierarchy, **Owner > Admin > Viewer**: + +| 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). + +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`) 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 + +- Identity-provider **group** grants. Access is granted to individual users only. Group-based access remains unresolved (`web-console/user_flows.md`). +- 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, below `gateway:owner` in the hierarchy. + +`gateway:admin` SHALL confer, on its bound gateway: + +- Read and update the gateway (including rename). +- 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` 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`. + +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). + +#### 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 + +#### 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` + +#### 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 + +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; 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 "Name" column | +| `email` | string | Optional | +| `role` | string | `owner`, `admin`, or `user` (the console tier) mapping to `gateway:owner`, `gateway:admin`, `gateway:viewer` | +| `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). + +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 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`, and `role` populated +- AND exactly two items SHALL have `role: owner` + +#### 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 `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` 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. + +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). + +#### 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: 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` + +#### 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` + +#### 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. + +**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). + +#### 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: 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 +- 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: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 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: 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 + +#### 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 -- Last-Owner Protection + +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): + +- 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 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 + +- 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: Sole owner cannot be removed + +- 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, 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. + +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 + +#### 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 + +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 directory is the Keycloak realm, not the HyperShell users inventory). + +**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, or `platform:admin`). + +#### 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 + +- 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; 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 + +- 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 `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 + +- 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 last-owner protection + +- 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 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 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`), 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. + +`make generate` SHALL regenerate the Go and TypeScript SDK clients for the new endpoints. + +#### 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 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-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..c49be5b65 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,40 @@ 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` 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 +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 +292,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 +375,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 +424,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 +496,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..750137a3a 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: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`. --- @@ -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 { @@ -105,9 +106,10 @@ 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) | Full CRUD on one gateway; can grant `gateway:owner` 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 | @@ -115,26 +117,34 @@ 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. | -- | +| `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. | -- | | `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,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. +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. @@ -262,60 +277,84 @@ 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. +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 - 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 assigns a co-owner - 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:owner`, `gateway_id=gw-1`, `user_id=B` - THEN the binding is created -- AND user B gains full access to gw-1 +- AND user B gains full owner access to gw-1 (including deleting the gateway and assigning owners) + +#### Scenario: Admin can grant admin and viewer + +- GIVEN user B has `gateway:admin` on gw-1 +- WHEN user B grants `role=gateway:viewer` (or `gateway:admin`), `gateway_id=gw-1`, `user_id=D` +- THEN the binding is created + +#### Scenario: Admin cannot assign an owner + +- 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 - 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 -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`) -- Grant or revoke RoleBindings (requires `gateway:owner` on that specific gateway) +- Modify gateway configuration (PATCH/PUT operations require `gateway:owner` or `gateway:admin`) +- 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. @@ -345,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 @@ -359,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/architecture.spec.md b/specs/web-console/architecture.spec.md index eea1d507e..cf5201c2b 100644 --- a/specs/web-console/architecture.spec.md +++ b/specs/web-console/architecture.spec.md @@ -350,8 +350,12 @@ 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`. + The `Connection` tab SHALL show this OpenShell CLI installation command in a code block: ```bash @@ -409,11 +413,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 +425,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 +458,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 @@ -682,7 +687,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..6196f7c10 --- /dev/null +++ b/specs/web-console/gateway-access-management.spec.md @@ -0,0 +1,229 @@ +# 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 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` + +--- + +## 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) | +| --- | --- | +| 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 | + +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 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) + +--- + +### Requirement: GAM-UI-03 -- Search and Role Filter + +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. + +#### 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 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), 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 + +- 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: 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** +- 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 + +--- + +### Requirement: GAM-UI-06 -- Remove Access + +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 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 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` + +--- + +### 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 -- 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 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. + +#### 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 + +#### 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 -- 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). The explanation SHALL be surfaced as a tooltip on hover and focus over the disabled role control and Remove access action (the control stays focusable, e.g. aria-disabled, so the reason is announced), consistent with the disabled "Delete gateway" affordance (WEB-UI-03). The same disabled-with-tooltip treatment SHALL apply to owner rows a non-owner caller may not manage (GAM-UI-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. + +#### Scenario: Sole owner 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 + +#### 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 + +--- + +### 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` (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 + +--- + +### 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, 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, 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, an admin, and a user. + +#### Scenario: CI exercises last-owner protection and probes in the UI + +- WHEN the UI test suite runs +- 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 9ceb57e7e..03adde03e 100644 --- a/specs/web-console/user_flows.md +++ b/specs/web-console/user_flows.md @@ -259,6 +259,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 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 | @@ -358,29 +361,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 **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: 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 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. 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 --- @@ -390,11 +388,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 Owner, Admin, or User"] role --> grant["Grant access"] grant --> success{"Access granted?"} success -- No --> error["Show error and keep selections"] 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 4bf0a0ac9..310a137f5 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]" @@ -1897,6 +1898,531 @@ 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=$(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) + 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 + + # == 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=$(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 \ + "${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 + # ── 10. platform admin RBAC verification ───────────────────────────────── echo ""