diff --git a/api/members/members_client/cluster_contract_test.go b/api/members/members_client/cluster_contract_test.go new file mode 100644 index 0000000..5fe415f --- /dev/null +++ b/api/members/members_client/cluster_contract_test.go @@ -0,0 +1,305 @@ +package members_client_test + +import ( + "context" + "fmt" + "os" + "path/filepath" + "reflect" + "regexp" + "strings" + "testing" + + "code.tnxs.net/vernonkeenan/lib/api/members/members_client" + "code.tnxs.net/vernonkeenan/lib/api/members/members_client/clusters" + "code.tnxs.net/vernonkeenan/lib/api/members/members_models" + openapiruntime "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" +) + +var clusterTestAuth = openapiruntime.ClientAuthInfoWriterFunc( + func(openapiruntime.ClientRequest, strfmt.Registry) error { return nil }, +) + +type clusterCaptureTransport struct { + operation *openapiruntime.ClientOperation +} + +func (transport *clusterCaptureTransport) Submit( + operation *openapiruntime.ClientOperation, +) (any, error) { + return transport.SubmitContext(context.Background(), operation) +} + +func (transport *clusterCaptureTransport) SubmitContext( + _ context.Context, + operation *openapiruntime.ClientOperation, +) (any, error) { + transport.operation = operation + switch operation.ID { + case "getClusters": + return clusters.NewGetClustersOK(), nil + case "postClusters": + return clusters.NewPostClustersOK(), nil + case "putClusters": + return clusters.NewPutClustersOK(), nil + default: + panic("unexpected generated Cluster operation: " + operation.ID) + } +} + +func TestGeneratedClusterOperationContract(t *testing.T) { + type operationCall func(openapiruntime.ContextualTransport) error + tests := []struct { + name, wantID, wantMethod, wantPath string + call operationCall + }{ + { + name: "list or get Cluster", wantID: "getClusters", + wantMethod: "GET", wantPath: "/clusters", + call: func(transport openapiruntime.ContextualTransport) error { + _, err := clusters.New(transport, strfmt.Default).GetClusters( + clusters.NewGetClustersParams().WithTenantID("tenant-example"), + clusterTestAuth, + ) + return err + }, + }, + { + name: "create Cluster", wantID: "postClusters", + wantMethod: "POST", wantPath: "/clusters", + call: func(transport openapiruntime.ContextualTransport) error { + _, err := clusters.New(transport, strfmt.Default).PostClusters( + clusters.NewPostClustersParams().WithTenantID("tenant-example"), + clusterTestAuth, + ) + return err + }, + }, + { + name: "CAS update Cluster", wantID: "putClusters", + wantMethod: "PUT", wantPath: "/clusters", + call: func(transport openapiruntime.ContextualTransport) error { + _, err := clusters.New(transport, strfmt.Default).PutClusters( + clusters.NewPutClustersParams().WithTenantID("tenant-example"), + clusterTestAuth, + ) + return err + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + transport := &clusterCaptureTransport{} + if err := test.call(transport); err != nil { + t.Fatalf("generated client operation failed: %v", err) + } + if transport.operation == nil { + t.Fatal("generated client did not submit an operation") + } + if transport.operation.ID != test.wantID { + t.Errorf("operation ID = %q, want %q", transport.operation.ID, test.wantID) + } + if transport.operation.Method != test.wantMethod { + t.Errorf("method = %q, want %q", transport.operation.Method, test.wantMethod) + } + if transport.operation.PathPattern != test.wantPath { + t.Errorf("path = %q, want %q", transport.operation.PathPattern, test.wantPath) + } + if transport.operation.AuthInfo == nil { + t.Error("generated operation discarded its compound auth-info writer") + } + }) + } +} + +func TestClusterTenantBindingCompoundAuthExactScopesAndCAS(t *testing.T) { + specText := readMembersLearningSpec(t) + pathBlock := learningSpecPathBlock(t, specText, "/clusters") + for _, forbidden := range []string{" delete:", " patch:"} { + if strings.Contains(pathBlock, forbidden) { + t.Errorf("/clusters exposes unsupported %s", strings.TrimSpace(forbidden)) + } + } + + operations := map[string]struct { + id, scope string + }{ + "get": {id: "getClusters", scope: "members:cluster:read"}, + "post": {id: "postClusters", scope: "members:cluster:create"}, + "put": {id: "putClusters", scope: "members:cluster:update"}, + } + const compoundSecurity = " security:\n - ApiKeyAuth: []\n kvSessionCookie: []" + for method, expected := range operations { + block := learningSpecOperationBlock(t, specText, "/clusters", method) + if !strings.Contains(block, " operationId: "+expected.id+"\n") { + t.Errorf("%s /clusters lost operationId %q", strings.ToUpper(method), expected.id) + } + if strings.Count(block, compoundSecurity) != 1 { + t.Errorf("%s /clusters does not preserve compound authentication", strings.ToUpper(method)) + } + if !strings.Contains(block, expected.scope) { + t.Errorf("%s /clusters lost exact scope %q", strings.ToUpper(method), expected.scope) + } + if !strings.Contains(block, `- $ref: "#/parameters/clusterTenantId"`) { + t.Errorf("%s /clusters is not tenantId-bound", strings.ToUpper(method)) + } + } + + recordID := "cluster-id" + getParams := clusters.NewGetClustersParams(). + WithTenantID("tenant-example"). + WithClusterID(&recordID) + if getParams.TenantID != "tenant-example" { + t.Fatal("Cluster list/get client lost the required tenantId") + } + if getParams.ClusterID == nil || *getParams.ClusterID != recordID { + t.Fatal("Cluster list/get client lost its clusterId filter") + } + if clusters.NewPostClustersParams().WithTenantID("tenant-example").TenantID != "tenant-example" { + t.Fatal("Cluster create client lost the required tenantId") + } + if clusters.NewPutClustersParams().WithTenantID("tenant-example").TenantID != "tenant-example" { + t.Fatal("Cluster update client lost the required tenantId") + } + if !clusters.NewPutClustersConflict().IsCode(409) { + t.Fatal("Cluster update client lost the optimistic concurrency response") + } +} + +func TestClusterModelAndSingleRecordRequestAreBounded(t *testing.T) { + assertExactModelFields(t, members_models.Cluster{}, map[string]string{ + "CreatedByID": "*string", "CreatedDate": "*string", "Description": "*string", + "Environment": "*string", "Gateway": "*string", "ID": "string", + "IPAddress": "*string", "LastModifiedByID": "*string", + "LastModifiedDate": "*string", "Name": "*string", "OwnerID": "*string", + "Ref": "*string", "Status": "*string", "Subnet": "*string", + "TenantID": "*string", "Type": "*string", "Zone": "*string", + }) + + firstName, secondName := "One", "Two" + request := &members_models.ClusterRequest{ + Data: []*members_models.Cluster{{Name: &firstName}, {Name: &secondName}}, + } + if err := request.Validate(strfmt.Default); err == nil { + t.Fatal("generated Cluster request accepted a bulk payload") + } + if err := (&members_models.ClusterRequest{ + Data: []*members_models.Cluster{{Name: &firstName}}, + }).Validate(strfmt.Default); err != nil { + t.Fatalf("generated Cluster request rejected one valid record: %v", err) + } +} + +func TestMembersCompoundAuthWritesBothCredentialsWithoutExposure(t *testing.T) { + const ( + apiKey = "test-api-key" + sessionToken = "test-session-token" + ) + writer, err := members_client.NewMembersCompoundAuth(apiKey, sessionToken) + if err != nil { + t.Fatalf("construct compound auth writer: %v", err) + } + request := new(openapiruntime.TestClientRequest) + if err := writer.AuthenticateRequest(request, strfmt.Default); err != nil { + t.Fatalf("write compound authentication: %v", err) + } + if got := request.Headers.Get("X-API-Key"); got != apiKey { + t.Errorf("X-API-Key = %q, want test value", got) + } + if got := request.Headers.Get("Cookie"); got != "kvSession="+sessionToken { + t.Errorf("Cookie = %q, want kvSession test value", got) + } + formatted := fmt.Sprintf("%v", writer) + if strings.Contains(formatted, apiKey) || strings.Contains(formatted, sessionToken) { + t.Fatal("compound auth writer formatting exposed credential material") + } +} + +func TestMembersCompoundAuthRejectsMissingOrUnsafeCredentialsWithoutEcho(t *testing.T) { + tests := []struct { + name, apiKey, sessionToken string + }{ + {name: "missing API key", sessionToken: "session"}, + {name: "missing session", apiKey: "api-key"}, + {name: "API key header injection", apiKey: "api-key\r\nunsafe", sessionToken: "session"}, + {name: "session cookie injection", apiKey: "api-key", sessionToken: "session;unsafe"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if writer, err := members_client.NewMembersCompoundAuth( + test.apiKey, test.sessionToken, + ); err == nil || writer != nil { + t.Fatal("unsafe compound authentication was accepted") + } else if (test.apiKey != "" && strings.Contains(err.Error(), test.apiKey)) || + (test.sessionToken != "" && strings.Contains(err.Error(), test.sessionToken)) { + t.Fatal("validation error exposed credential material") + } + }) + } +} + +func TestClusterSurfaceOmitsDeleteDatabaseAndDSN(t *testing.T) { + contract := reflect.TypeOf((*clusters.ClientService)(nil)).Elem() + for index := 0; index < contract.NumMethod(); index++ { + method := contract.Method(index).Name + for _, forbidden := range []string{"Delete", "Database", "DSN"} { + if strings.Contains(method, forbidden) { + t.Errorf("Cluster client exposes forbidden method %s", method) + } + } + } + + root := reflect.TypeOf(members_client.Members{}) + if _, exists := root.FieldByName("Clusters"); !exists { + t.Fatal("root Members client does not expose governed Clusters") + } +} + +func TestGeneratedClusterSurfaceHasNoExternalBypassOrSecrets(t *testing.T) { + repoRoot := learningRepoRoot(t) + targets := []string{ + filepath.Join(repoRoot, "api", "members", "members_client", "clusters"), + filepath.Join(repoRoot, "api", "members", "members_client", "compound_auth.go"), + filepath.Join(repoRoot, "api", "members", "members_models", "cluster.go"), + filepath.Join(repoRoot, "api", "members", "members_models", "cluster_request.go"), + filepath.Join(repoRoot, "api", "members", "members_models", "cluster_response.go"), + } + credentialLiteral := regexp.MustCompile( + `(?i)(api[_-]?key|password|secret)\s*[:=]\s*["'][^"']+["']`, + ) + for _, target := range targets { + err := filepath.WalkDir(target, func( + path string, entry os.DirEntry, walkErr error, + ) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || !strings.HasSuffix(path, ".go") || + strings.HasSuffix(path, "_test.go") { + return nil + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + lower := strings.ToLower(string(content)) + for _, forbidden := range []string{ + "salesforce", "sf-gate", "go-force", "private key-----", + "members_client/databases", "dsn", + } { + if strings.Contains(lower, forbidden) { + t.Errorf("%s contains forbidden boundary %q", path, forbidden) + } + } + if credentialLiteral.Match(content) { + t.Errorf("%s contains a credential-shaped literal", path) + } + return nil + }) + if err != nil { + t.Fatalf("scan generated Cluster target %s: %v", target, err) + } + } +} diff --git a/api/members/members_client/clusters/clusters_client.go b/api/members/members_client/clusters/clusters_client.go index d49c518..caf648f 100644 --- a/api/members/members_client/clusters/clusters_client.go +++ b/api/members/members_client/clusters/clusters_client.go @@ -60,30 +60,30 @@ type ClientOption func(*runtime.ClientOperation) // ClientService is the interface for Client methods. type ClientService interface { - // GetClusters get a list clusters. + // GetClusters get governed tenant clusters. GetClusters(params *GetClustersParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetClustersOK, error) - // GetClustersContext get a list clusters. + // GetClustersContext get governed tenant clusters. GetClustersContext(ctx context.Context, params *GetClustersParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetClustersOK, error) - // PostClusters create new clusters. + // PostClusters create a governed cluster. PostClusters(params *PostClustersParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*PostClustersOK, error) - // PostClustersContext create new clusters. + // PostClustersContext create a governed cluster. PostClustersContext(ctx context.Context, params *PostClustersParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*PostClustersOK, error) - // PutClusters update clustera. + // PutClusters update a governed cluster. PutClusters(params *PutClustersParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*PutClustersOK, error) - // PutClustersContext update clustera. + // PutClustersContext update a governed cluster. PutClustersContext(ctx context.Context, params *PutClustersParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*PutClustersOK, error) SetTransport(transport runtime.ContextualTransport) } -// GetClusters gets a list clusters. +// GetClusters gets governed tenant clusters. // -// Return a list of Cluster records from the datastore. +// Return tenant-scoped Cluster records. Requires members:cluster:read and an active human membership in tenantId.. // // This method does not support injected context. // However, timeout and opentracing contexts are honored whenever enabled. @@ -100,9 +100,9 @@ func (a *Client) GetClusters(params *GetClustersParams, authInfo runtime.ClientA return a.GetClustersContext(ctx, params, authInfo, opts...) } -// GetClustersContext gets a list clusters. +// GetClustersContext gets governed tenant clusters. // -// Return a list of Cluster records from the datastore. +// Return tenant-scoped Cluster records. Requires members:cluster:read and an active human membership in tenantId.. // // Do not use the deprecated [GetClustersParams.Context] with this method: it would be ignored. func (a *Client) GetClustersContext(ctx context.Context, params *GetClustersParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetClustersOK, error) { @@ -148,9 +148,9 @@ func (a *Client) GetClustersContext(ctx context.Context, params *GetClustersPara panic(msg) } -// PostClusters creates new clusters. +// PostClusters creates a governed cluster. // -// Create Clusters. +// Create one tenant-scoped Cluster. Requires members:cluster:create and active Owner or Manager access. ID, TenantID, and audit fields are server-owned.. // // This method does not support injected context. // However, timeout and opentracing contexts are honored whenever enabled. @@ -167,9 +167,9 @@ func (a *Client) PostClusters(params *PostClustersParams, authInfo runtime.Clien return a.PostClustersContext(ctx, params, authInfo, opts...) } -// PostClustersContext creates new clusters. +// PostClustersContext creates a governed cluster. // -// Create Clusters. +// Create one tenant-scoped Cluster. Requires members:cluster:create and active Owner or Manager access. ID, TenantID, and audit fields are server-owned.. // // Do not use the deprecated [PostClustersParams.Context] with this method: it would be ignored. func (a *Client) PostClustersContext(ctx context.Context, params *PostClustersParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*PostClustersOK, error) { @@ -215,9 +215,9 @@ func (a *Client) PostClustersContext(ctx context.Context, params *PostClustersPa panic(msg) } -// PutClusters updates clustera. +// PutClusters updates a governed cluster. // -// Update Cluster. +// CAS-update one tenant-scoped Cluster. Requires members:cluster:update, active Owner or Manager access, and the current LastModifiedDate. TenantID and audit fields remain server-owned.. // // This method does not support injected context. // However, timeout and opentracing contexts are honored whenever enabled. @@ -234,9 +234,9 @@ func (a *Client) PutClusters(params *PutClustersParams, authInfo runtime.ClientA return a.PutClustersContext(ctx, params, authInfo, opts...) } -// PutClustersContext updates clustera. +// PutClustersContext updates a governed cluster. // -// Update Cluster. +// CAS-update one tenant-scoped Cluster. Requires members:cluster:update, active Owner or Manager access, and the current LastModifiedDate. TenantID and audit fields remain server-owned.. // // Do not use the deprecated [PutClustersParams.Context] with this method: it would be ignored. func (a *Client) PutClustersContext(ctx context.Context, params *PutClustersParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*PutClustersOK, error) { diff --git a/api/members/members_client/clusters/get_clusters_parameters.go b/api/members/members_client/clusters/get_clusters_parameters.go index d81fdab..af7a6ff 100644 --- a/api/members/members_client/clusters/get_clusters_parameters.go +++ b/api/members/members_client/clusters/get_clusters_parameters.go @@ -86,6 +86,11 @@ type GetClustersParams struct { // Format: int64 Offset *int64 + // TenantID. + // + // Tenant that owns the Cluster. Members verifies the active human membership and never accepts TenantID from the body. + TenantID string + HTTPClient *http.Client inner innerParams @@ -176,6 +181,17 @@ func (o *GetClustersParams) SetOffset(offset *int64) { o.Offset = offset } +// WithTenantID adds the tenantID to the get clusters params. +func (o *GetClustersParams) WithTenantID(tenantID string) *GetClustersParams { + o.SetTenantID(tenantID) + return o +} + +// SetTenantID adds the tenantId to the get clusters params. +func (o *GetClustersParams) SetTenantID(tenantID string) { + o.TenantID = tenantID +} + // WriteToRequest writes these params to a [runtime.ClientRequest]. func (o *GetClustersParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { if err := r.SetTimeout(o.inner.timeout); err != nil { @@ -234,6 +250,16 @@ func (o *GetClustersParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.R } } + // query param tenantId + qrTenantID := o.TenantID + qTenantID := qrTenantID + if qTenantID != "" { + + if err := r.SetQueryParam("tenantId", qTenantID); err != nil { + return err + } + } + if len(res) > 0 { return errors.CompositeValidationError(res...) } diff --git a/api/members/members_client/clusters/post_clusters_parameters.go b/api/members/members_client/clusters/post_clusters_parameters.go index c1f3322..a3cf582 100644 --- a/api/members/members_client/clusters/post_clusters_parameters.go +++ b/api/members/members_client/clusters/post_clusters_parameters.go @@ -69,9 +69,14 @@ type PostClustersParams struct { // ClusterRequest. // - // An array of Cluster records + // Exactly one governed Cluster record ClusterRequest *members_models.ClusterRequest + // TenantID. + // + // Tenant that owns the Cluster. Members verifies the active human membership and never accepts TenantID from the body. + TenantID string + HTTPClient *http.Client inner innerParams @@ -140,6 +145,17 @@ func (o *PostClustersParams) SetClusterRequest(clusterRequest *members_models.Cl o.ClusterRequest = clusterRequest } +// WithTenantID adds the tenantID to the post clusters params. +func (o *PostClustersParams) WithTenantID(tenantID string) *PostClustersParams { + o.SetTenantID(tenantID) + return o +} + +// SetTenantID adds the tenantId to the post clusters params. +func (o *PostClustersParams) SetTenantID(tenantID string) { + o.TenantID = tenantID +} + // WriteToRequest writes these params to a [runtime.ClientRequest]. func (o *PostClustersParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { if err := r.SetTimeout(o.inner.timeout); err != nil { @@ -152,6 +168,16 @@ func (o *PostClustersParams) WriteToRequest(r runtime.ClientRequest, reg strfmt. } } + // query param tenantId + qrTenantID := o.TenantID + qTenantID := qrTenantID + if qTenantID != "" { + + if err := r.SetQueryParam("tenantId", qTenantID); err != nil { + return err + } + } + if len(res) > 0 { return errors.CompositeValidationError(res...) } diff --git a/api/members/members_client/clusters/put_clusters_parameters.go b/api/members/members_client/clusters/put_clusters_parameters.go index a2567fd..fad61b4 100644 --- a/api/members/members_client/clusters/put_clusters_parameters.go +++ b/api/members/members_client/clusters/put_clusters_parameters.go @@ -69,9 +69,14 @@ type PutClustersParams struct { // ClusterRequest. // - // An array of Cluster records + // Exactly one governed Cluster record ClusterRequest *members_models.ClusterRequest + // TenantID. + // + // Tenant that owns the Cluster. Members verifies the active human membership and never accepts TenantID from the body. + TenantID string + HTTPClient *http.Client inner innerParams @@ -140,6 +145,17 @@ func (o *PutClustersParams) SetClusterRequest(clusterRequest *members_models.Clu o.ClusterRequest = clusterRequest } +// WithTenantID adds the tenantID to the put clusters params. +func (o *PutClustersParams) WithTenantID(tenantID string) *PutClustersParams { + o.SetTenantID(tenantID) + return o +} + +// SetTenantID adds the tenantId to the put clusters params. +func (o *PutClustersParams) SetTenantID(tenantID string) { + o.TenantID = tenantID +} + // WriteToRequest writes these params to a [runtime.ClientRequest]. func (o *PutClustersParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { if err := r.SetTimeout(o.inner.timeout); err != nil { @@ -152,6 +168,16 @@ func (o *PutClustersParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.R } } + // query param tenantId + qrTenantID := o.TenantID + qTenantID := qrTenantID + if qTenantID != "" { + + if err := r.SetQueryParam("tenantId", qTenantID); err != nil { + return err + } + } + if len(res) > 0 { return errors.CompositeValidationError(res...) } diff --git a/api/members/members_client/clusters/put_clusters_responses.go b/api/members/members_client/clusters/put_clusters_responses.go index d36c74c..4fbb72d 100644 --- a/api/members/members_client/clusters/put_clusters_responses.go +++ b/api/members/members_client/clusters/put_clusters_responses.go @@ -49,6 +49,12 @@ func (o *PutClustersReader) ReadResponse(response runtime.ClientResponse, consum return nil, err } return nil, result + case 409: + result := NewPutClustersConflict() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result case 422: result := NewPutClustersUnprocessableEntity() if err := result.readResponse(response, consumer, o.formats); err != nil { @@ -347,6 +353,74 @@ func (o *PutClustersNotFound) readResponse(response runtime.ClientResponse, cons return nil } +// NewPutClustersConflict creates a PutClustersConflict with default headers values +func NewPutClustersConflict() *PutClustersConflict { + return &PutClustersConflict{} +} + +// PutClustersConflict describes a response with status code 409, with default header values. +// +// The supplied LastModifiedDate is stale; reload the record before retrying. +type PutClustersConflict struct { + Payload *members_models.Error +} + +// IsSuccess returns true when this put clusters conflict response has a 2xx status code +func (o *PutClustersConflict) IsSuccess() bool { + return false +} + +// IsRedirect returns true when this put clusters conflict response has a 3xx status code +func (o *PutClustersConflict) IsRedirect() bool { + return false +} + +// IsClientError returns true when this put clusters conflict response has a 4xx status code +func (o *PutClustersConflict) IsClientError() bool { + return true +} + +// IsServerError returns true when this put clusters conflict response has a 5xx status code +func (o *PutClustersConflict) IsServerError() bool { + return false +} + +// IsCode returns true when this put clusters conflict response a status code equal to that given +func (o *PutClustersConflict) IsCode(code int) bool { + return code == 409 +} + +// Code gets the status code for the put clusters conflict response +func (o *PutClustersConflict) Code() int { + return 409 +} + +func (o *PutClustersConflict) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /clusters][%d] putClustersConflict %s", 409, payload) +} + +func (o *PutClustersConflict) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /clusters][%d] putClustersConflict %s", 409, payload) +} + +func (o *PutClustersConflict) GetPayload() *members_models.Error { + return o.Payload +} + +func (o *PutClustersConflict) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(members_models.Error) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + // NewPutClustersUnprocessableEntity creates a PutClustersUnprocessableEntity with default headers values func NewPutClustersUnprocessableEntity() *PutClustersUnprocessableEntity { return &PutClustersUnprocessableEntity{} diff --git a/api/members/members_client/compound_auth.go b/api/members/members_client/compound_auth.go new file mode 100644 index 0000000..e75ef7f --- /dev/null +++ b/api/members/members_client/compound_auth.go @@ -0,0 +1,67 @@ +// (c) 2012-2020 by Taxnexus, Inc. +// All rights reserved worldwide. +// Proprietary product; unlicensed use is not allowed + +package members_client + +import ( + "errors" + "fmt" + + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" +) + +const maxCredentialLength = 8192 + +// NewMembersCompoundAuth returns the auth writer required by governed Members +// operations: a service API key plus the active human kvSession cookie. +// +// Credential values are retained only inside the returned closure. Validation +// errors identify the invalid field without including its value. +func NewMembersCompoundAuth(apiKey, sessionToken string) (runtime.ClientAuthInfoWriter, error) { + if err := validateHeaderCredential("Members API key", apiKey); err != nil { + return nil, err + } + if err := validateCookieCredential("Members session token", sessionToken); err != nil { + return nil, err + } + + return runtime.ClientAuthInfoWriterFunc(func(request runtime.ClientRequest, _ strfmt.Registry) error { + if err := request.SetHeaderParam("X-API-Key", apiKey); err != nil { + return fmt.Errorf("set Members API key header: %w", err) + } + if err := request.SetHeaderParam("Cookie", "kvSession="+sessionToken); err != nil { + return fmt.Errorf("set Members session cookie: %w", err) + } + return nil + }), nil +} + +func validateHeaderCredential(name, value string) error { + if value == "" { + return fmt.Errorf("%s is required", name) + } + if len(value) > maxCredentialLength { + return fmt.Errorf("%s exceeds the maximum length", name) + } + for index := 0; index < len(value); index++ { + if value[index] < 0x21 || value[index] > 0x7e { + return fmt.Errorf("%s contains an invalid header byte", name) + } + } + return nil +} + +func validateCookieCredential(name, value string) error { + if err := validateHeaderCredential(name, value); err != nil { + return err + } + for index := 0; index < len(value); index++ { + character := value[index] + if character == '"' || character == ',' || character == ';' || character == '\\' { + return errors.New(name + " contains an invalid cookie byte") + } + } + return nil +} diff --git a/api/members/members_client/learning_contract_test.go b/api/members/members_client/learning_contract_test.go index 569cd42..efbcf06 100644 --- a/api/members/members_client/learning_contract_test.go +++ b/api/members/members_client/learning_contract_test.go @@ -23,7 +23,7 @@ import ( "github.com/go-openapi/strfmt" ) -const membersProviderSpecSHA256 = "091a5ea132ea076ecfd391a02458e4a2445aa16dee89487b0aef977631766c4c" +const membersProviderSpecSHA256 = "707dcee4b2078d431ba7f6c6a81105fb1c94c4b0b9298fa04567e63343af9a9d" var learningTestAuth = openapiruntime.ClientAuthInfoWriterFunc(func(openapiruntime.ClientRequest, strfmt.Registry) error { return nil @@ -517,7 +517,7 @@ func TestMembersSpecIsPinnedToProviderSource(t *testing.T) { } sum := sha256.Sum256(mainSpec) if got := hex.EncodeToString(sum[:]); got != membersProviderSpecSHA256 { - t.Fatalf("Members spec drifted from integrated Track provider commit 6bfdcbe: SHA-256 = %s, want %s", got, membersProviderSpecSHA256) + t.Fatalf("Members spec drifted from integrated Cluster provider commit a7cedc5: SHA-256 = %s, want %s", got, membersProviderSpecSHA256) } externalSpec, err := os.ReadFile(filepath.Join(repoRoot, "swagger", "external", "members-vernonkeenan.yaml")) diff --git a/api/members/members_client/track_contract_test.go b/api/members/members_client/track_contract_test.go index fcd15ef..81f6df1 100644 --- a/api/members/members_client/track_contract_test.go +++ b/api/members/members_client/track_contract_test.go @@ -19,7 +19,7 @@ import ( "github.com/go-openapi/strfmt" ) -const membersTrackSpecSHA256 = "091a5ea132ea076ecfd391a02458e4a2445aa16dee89487b0aef977631766c4c" +const membersTrackSpecSHA256 = "707dcee4b2078d431ba7f6c6a81105fb1c94c4b0b9298fa04567e63343af9a9d" var trackTestAuth = openapiruntime.ClientAuthInfoWriterFunc( func(openapiruntime.ClientRequest, strfmt.Registry) error { return nil }, @@ -233,7 +233,7 @@ func TestMembersSpecIsPinnedToTrackProviderCommit(t *testing.T) { sum := sha256.Sum256(mainSpec) if got := hex.EncodeToString(sum[:]); got != membersTrackSpecSHA256 { t.Fatalf( - "Members spec drifted from integrated Track provider commit 6bfdcbe: SHA-256 = %s, want %s", + "Members spec drifted from integrated Cluster provider commit a7cedc5: SHA-256 = %s, want %s", got, membersTrackSpecSHA256, ) } diff --git a/api/members/members_models/cluster.go b/api/members/members_models/cluster.go index c0915a8..2f37728 100644 --- a/api/members/members_models/cluster.go +++ b/api/members/members_models/cluster.go @@ -9,74 +9,439 @@ package members_models import ( "context" + "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag/jsonutils" + "github.com/go-openapi/swag/typeutils" + "github.com/go-openapi/validate" ) -// Cluster cluster +// Cluster Tenant-owned infrastructure metadata. TenantID, identity, and audit fields are server-owned. // -// swagger:model cluster +// swagger:model Cluster type Cluster struct { - // Created By + // created by ID + // Read Only: true + // Max Length: 36 CreatedByID *string `json:"CreatedByID,omitempty"` - // Created Date + // created date + // Read Only: true + // Max Length: 64 CreatedDate *string `json:"CreatedDate,omitempty"` - // Description + // description + // Max Length: 255 Description *string `json:"Description,omitempty"` - // Environment + // environment + // Max Length: 255 Environment *string `json:"Environment,omitempty"` - // Gateway + // gateway + // Max Length: 255 Gateway *string `json:"Gateway,omitempty"` - // Record Id + // ID + // Max Length: 36 ID string `json:"ID,omitempty"` - // IP Address + // IP address + // Max Length: 255 IPAddress *string `json:"IPAddress,omitempty"` - // Last Modified By + // last modified by ID + // Read Only: true + // Max Length: 36 LastModifiedByID *string `json:"LastModifiedByID,omitempty"` - // Last Modified Date + // last modified date + // Max Length: 64 LastModifiedDate *string `json:"LastModifiedDate,omitempty"` - // Cluster Name - Name *string `json:"Name,omitempty"` + // name + // Required: true + // Max Length: 255 + // Min Length: 1 + Name *string `json:"Name"` - // Owner + // owner ID + // Max Length: 36 OwnerID *string `json:"OwnerID,omitempty"` - // External Reference + // ref + // Max Length: 255 Ref *string `json:"Ref,omitempty"` - // Status + // status + // Max Length: 255 Status *string `json:"Status,omitempty"` - // Subnet + // subnet + // Max Length: 255 Subnet *string `json:"Subnet,omitempty"` - // The ID of the tenant who owns this Database + // tenant ID + // Read Only: true + // Max Length: 36 TenantID *string `json:"TenantID,omitempty"` - // Type + // type + // Max Length: 255 Type *string `json:"Type,omitempty"` - // Zone + // zone + // Max Length: 255 Zone *string `json:"Zone,omitempty"` } // Validate validates this cluster func (m *Cluster) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateCreatedByID(formats); err != nil { + res = append(res, err) + } + + if err := m.validateCreatedDate(formats); err != nil { + res = append(res, err) + } + + if err := m.validateDescription(formats); err != nil { + res = append(res, err) + } + + if err := m.validateEnvironment(formats); err != nil { + res = append(res, err) + } + + if err := m.validateGateway(formats); err != nil { + res = append(res, err) + } + + if err := m.validateID(formats); err != nil { + res = append(res, err) + } + + if err := m.validateIPAddress(formats); err != nil { + res = append(res, err) + } + + if err := m.validateLastModifiedByID(formats); err != nil { + res = append(res, err) + } + + if err := m.validateLastModifiedDate(formats); err != nil { + res = append(res, err) + } + + if err := m.validateName(formats); err != nil { + res = append(res, err) + } + + if err := m.validateOwnerID(formats); err != nil { + res = append(res, err) + } + + if err := m.validateRef(formats); err != nil { + res = append(res, err) + } + + if err := m.validateStatus(formats); err != nil { + res = append(res, err) + } + + if err := m.validateSubnet(formats); err != nil { + res = append(res, err) + } + + if err := m.validateTenantID(formats); err != nil { + res = append(res, err) + } + + if err := m.validateType(formats); err != nil { + res = append(res, err) + } + + if err := m.validateZone(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } return nil } -// ContextValidate validates this cluster based on context it is used +func (m *Cluster) validateCreatedByID(formats strfmt.Registry) error { + if typeutils.IsZero(m.CreatedByID) { // not required + return nil + } + + if err := validate.MaxLength("CreatedByID", "body", *m.CreatedByID, 36); err != nil { + return err + } + + return nil +} + +func (m *Cluster) validateCreatedDate(formats strfmt.Registry) error { + if typeutils.IsZero(m.CreatedDate) { // not required + return nil + } + + if err := validate.MaxLength("CreatedDate", "body", *m.CreatedDate, 64); err != nil { + return err + } + + return nil +} + +func (m *Cluster) validateDescription(formats strfmt.Registry) error { + if typeutils.IsZero(m.Description) { // not required + return nil + } + + if err := validate.MaxLength("Description", "body", *m.Description, 255); err != nil { + return err + } + + return nil +} + +func (m *Cluster) validateEnvironment(formats strfmt.Registry) error { + if typeutils.IsZero(m.Environment) { // not required + return nil + } + + if err := validate.MaxLength("Environment", "body", *m.Environment, 255); err != nil { + return err + } + + return nil +} + +func (m *Cluster) validateGateway(formats strfmt.Registry) error { + if typeutils.IsZero(m.Gateway) { // not required + return nil + } + + if err := validate.MaxLength("Gateway", "body", *m.Gateway, 255); err != nil { + return err + } + + return nil +} + +func (m *Cluster) validateID(formats strfmt.Registry) error { + if typeutils.IsZero(m.ID) { // not required + return nil + } + + if err := validate.MaxLength("ID", "body", m.ID, 36); err != nil { + return err + } + + return nil +} + +func (m *Cluster) validateIPAddress(formats strfmt.Registry) error { + if typeutils.IsZero(m.IPAddress) { // not required + return nil + } + + if err := validate.MaxLength("IPAddress", "body", *m.IPAddress, 255); err != nil { + return err + } + + return nil +} + +func (m *Cluster) validateLastModifiedByID(formats strfmt.Registry) error { + if typeutils.IsZero(m.LastModifiedByID) { // not required + return nil + } + + if err := validate.MaxLength("LastModifiedByID", "body", *m.LastModifiedByID, 36); err != nil { + return err + } + + return nil +} + +func (m *Cluster) validateLastModifiedDate(formats strfmt.Registry) error { + if typeutils.IsZero(m.LastModifiedDate) { // not required + return nil + } + + if err := validate.MaxLength("LastModifiedDate", "body", *m.LastModifiedDate, 64); err != nil { + return err + } + + return nil +} + +func (m *Cluster) validateName(formats strfmt.Registry) error { + + if err := validate.Required("Name", "body", m.Name); err != nil { + return err + } + + if err := validate.MinLength("Name", "body", *m.Name, 1); err != nil { + return err + } + + if err := validate.MaxLength("Name", "body", *m.Name, 255); err != nil { + return err + } + + return nil +} + +func (m *Cluster) validateOwnerID(formats strfmt.Registry) error { + if typeutils.IsZero(m.OwnerID) { // not required + return nil + } + + if err := validate.MaxLength("OwnerID", "body", *m.OwnerID, 36); err != nil { + return err + } + + return nil +} + +func (m *Cluster) validateRef(formats strfmt.Registry) error { + if typeutils.IsZero(m.Ref) { // not required + return nil + } + + if err := validate.MaxLength("Ref", "body", *m.Ref, 255); err != nil { + return err + } + + return nil +} + +func (m *Cluster) validateStatus(formats strfmt.Registry) error { + if typeutils.IsZero(m.Status) { // not required + return nil + } + + if err := validate.MaxLength("Status", "body", *m.Status, 255); err != nil { + return err + } + + return nil +} + +func (m *Cluster) validateSubnet(formats strfmt.Registry) error { + if typeutils.IsZero(m.Subnet) { // not required + return nil + } + + if err := validate.MaxLength("Subnet", "body", *m.Subnet, 255); err != nil { + return err + } + + return nil +} + +func (m *Cluster) validateTenantID(formats strfmt.Registry) error { + if typeutils.IsZero(m.TenantID) { // not required + return nil + } + + if err := validate.MaxLength("TenantID", "body", *m.TenantID, 36); err != nil { + return err + } + + return nil +} + +func (m *Cluster) validateType(formats strfmt.Registry) error { + if typeutils.IsZero(m.Type) { // not required + return nil + } + + if err := validate.MaxLength("Type", "body", *m.Type, 255); err != nil { + return err + } + + return nil +} + +func (m *Cluster) validateZone(formats strfmt.Registry) error { + if typeutils.IsZero(m.Zone) { // not required + return nil + } + + if err := validate.MaxLength("Zone", "body", *m.Zone, 255); err != nil { + return err + } + + return nil +} + +// ContextValidate validate this cluster based on the context it is used func (m *Cluster) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + var res []error + + if err := m.contextValidateCreatedByID(ctx, formats); err != nil { + res = append(res, err) + } + + if err := m.contextValidateCreatedDate(ctx, formats); err != nil { + res = append(res, err) + } + + if err := m.contextValidateLastModifiedByID(ctx, formats); err != nil { + res = append(res, err) + } + + if err := m.contextValidateTenantID(ctx, formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *Cluster) contextValidateCreatedByID(ctx context.Context, formats strfmt.Registry) error { + + if err := validate.ReadOnly(ctx, "CreatedByID", "body", m.CreatedByID); err != nil { + return err + } + + return nil +} + +func (m *Cluster) contextValidateCreatedDate(ctx context.Context, formats strfmt.Registry) error { + + if err := validate.ReadOnly(ctx, "CreatedDate", "body", m.CreatedDate); err != nil { + return err + } + + return nil +} + +func (m *Cluster) contextValidateLastModifiedByID(ctx context.Context, formats strfmt.Registry) error { + + if err := validate.ReadOnly(ctx, "LastModifiedByID", "body", m.LastModifiedByID); err != nil { + return err + } + + return nil +} + +func (m *Cluster) contextValidateTenantID(ctx context.Context, formats strfmt.Registry) error { + + if err := validate.ReadOnly(ctx, "TenantID", "body", m.TenantID); err != nil { + return err + } + return nil } diff --git a/api/members/members_models/cluster_request.go b/api/members/members_models/cluster_request.go index be72a28..9225764 100644 --- a/api/members/members_models/cluster_request.go +++ b/api/members/members_models/cluster_request.go @@ -15,14 +15,18 @@ import ( "github.com/go-openapi/strfmt" "github.com/go-openapi/swag/jsonutils" "github.com/go-openapi/swag/typeutils" + "github.com/go-openapi/validate" ) -// ClusterRequest cluster request +// ClusterRequest Exactly one governed Cluster object // // swagger:model ClusterRequest type ClusterRequest struct { // data + // Required: true + // Max Items: 1 + // Min Items: 1 Data []*Cluster `json:"Data"` } @@ -41,8 +45,19 @@ func (m *ClusterRequest) Validate(formats strfmt.Registry) error { } func (m *ClusterRequest) validateData(formats strfmt.Registry) error { - if typeutils.IsZero(m.Data) { // not required - return nil + + if err := validate.Required("Data", "body", m.Data); err != nil { + return err + } + + iDataSize := int64(len(m.Data)) + + if err := validate.MinItems("Data", "body", iDataSize, 1); err != nil { + return err + } + + if err := validate.MaxItems("Data", "body", iDataSize, 1); err != nil { + return err } for i := 0; i < len(m.Data); i++ { diff --git a/api/members/members_models/cluster_response.go b/api/members/members_models/cluster_response.go index cdbcd32..16fdb9e 100644 --- a/api/members/members_models/cluster_response.go +++ b/api/members/members_models/cluster_response.go @@ -17,7 +17,7 @@ import ( "github.com/go-openapi/swag/typeutils" ) -// ClusterResponse An array of cluster objects +// ClusterResponse An array of governed tenant Cluster objects // // swagger:model ClusterResponse type ClusterResponse struct { diff --git a/swagger/external/members-vernonkeenan.yaml b/swagger/external/members-vernonkeenan.yaml index 15a4aa6..ea1f8df 100644 --- a/swagger/external/members-vernonkeenan.yaml +++ b/swagger/external/members-vernonkeenan.yaml @@ -154,8 +154,16 @@ parameters: name: clusterId required: false type: string + clusterTenantId: + description: Tenant that owns the Cluster. Members verifies the active human membership and never accepts TenantID from the body. + in: query + name: tenantId + required: true + type: string + minLength: 1 + maxLength: 36 ClusterRequest: - description: An array of Cluster records + description: Exactly one governed Cluster record in: body name: ClusterRequest required: true @@ -1807,10 +1815,11 @@ paths: - Attendees /clusters: get: - description: Return a list of Cluster records from the datastore + description: Return tenant-scoped Cluster records. Requires members:cluster:read and an active human membership in tenantId. operationId: getClusters parameters: - $ref: "#/parameters/clusterIdQuery" + - $ref: "#/parameters/clusterTenantId" - $ref: "#/parameters/limitQuery" - $ref: "#/parameters/offsetQuery" responses: @@ -1828,14 +1837,16 @@ paths: $ref: "#/responses/ServerError" security: - ApiKeyAuth: [] - summary: Get a list Clusters + kvSessionCookie: [] + summary: Get governed tenant Clusters tags: - Clusters post: - description: Create Clusters + description: Create one tenant-scoped Cluster. Requires members:cluster:create and active Owner or Manager access. ID, TenantID, and audit fields are server-owned. operationId: postClusters parameters: - $ref: "#/parameters/ClusterRequest" + - $ref: "#/parameters/clusterTenantId" responses: "200": $ref: "#/responses/ClusterResponse" @@ -1851,14 +1862,16 @@ paths: $ref: "#/responses/ServerError" security: - ApiKeyAuth: [] - summary: Create new Clusters + kvSessionCookie: [] + summary: Create a governed Cluster tags: - Clusters put: - description: Update Cluster + description: CAS-update one tenant-scoped Cluster. Requires members:cluster:update, active Owner or Manager access, and the current LastModifiedDate. TenantID and audit fields remain server-owned. operationId: putClusters parameters: - $ref: "#/parameters/ClusterRequest" + - $ref: "#/parameters/clusterTenantId" responses: "200": $ref: "#/responses/ClusterResponse" @@ -1866,6 +1879,8 @@ paths: $ref: "#/responses/Unauthorized" "403": $ref: "#/responses/AccessForbidden" + "409": + $ref: "#/responses/Conflict" "404": $ref: "#/responses/NotFound" "422": @@ -1874,7 +1889,8 @@ paths: $ref: "#/responses/ServerError" security: - ApiKeyAuth: [] - summary: Update Clustera + kvSessionCookie: [] + summary: Update a governed Cluster tags: - Clusters /courselessons: @@ -4363,6 +4379,83 @@ paths: tags: - Users definitions: + Cluster: + description: Tenant-owned infrastructure metadata. TenantID, identity, and audit fields are server-owned. + type: object + required: [Name] + properties: + ID: + type: string + maxLength: 36 + CreatedByID: + type: string + maxLength: 36 + x-nullable: true + readOnly: true + CreatedDate: + type: string + maxLength: 64 + x-nullable: true + readOnly: true + Description: + type: string + maxLength: 255 + x-nullable: true + Environment: + type: string + maxLength: 255 + x-nullable: true + Gateway: + type: string + maxLength: 255 + x-nullable: true + IPAddress: + type: string + maxLength: 255 + x-nullable: true + LastModifiedByID: + type: string + maxLength: 36 + x-nullable: true + readOnly: true + LastModifiedDate: + type: string + maxLength: 64 + x-nullable: true + Name: + type: string + minLength: 1 + maxLength: 255 + x-nullable: true + OwnerID: + type: string + maxLength: 36 + x-nullable: true + Ref: + type: string + maxLength: 255 + x-nullable: true + Status: + type: string + maxLength: 255 + x-nullable: true + Subnet: + type: string + maxLength: 255 + x-nullable: true + TenantID: + type: string + maxLength: 36 + x-nullable: true + readOnly: true + Type: + type: string + maxLength: 255 + x-nullable: true + Zone: + type: string + maxLength: 255 + x-nullable: true Track: description: A governed Track root. The schema has no tenant key, so Members derives ownership from the server-owned creator and active Keenan Vision tenant membership. type: object @@ -4793,18 +4886,22 @@ definitions: type: array type: object ClusterRequest: + description: Exactly one governed Cluster object + required: [Data] properties: Data: items: - $ref: "../../lib/swagger/defs/cluster.yaml#/Cluster" + $ref: "#/definitions/Cluster" + minItems: 1 + maxItems: 1 type: array type: object ClusterResponse: - description: An array of cluster objects + description: An array of governed tenant Cluster objects properties: Data: items: - $ref: "../../lib/swagger/defs/cluster.yaml#/Cluster" + $ref: "#/definitions/Cluster" type: array Meta: $ref: "#/definitions/ResponseMeta" diff --git a/swagger/members-vernonkeenan.yaml b/swagger/members-vernonkeenan.yaml index c798bb5..6f70c12 100644 --- a/swagger/members-vernonkeenan.yaml +++ b/swagger/members-vernonkeenan.yaml @@ -154,8 +154,16 @@ parameters: name: clusterId required: false type: string + clusterTenantId: + description: Tenant that owns the Cluster. Members verifies the active human membership and never accepts TenantID from the body. + in: query + name: tenantId + required: true + type: string + minLength: 1 + maxLength: 36 ClusterRequest: - description: An array of Cluster records + description: Exactly one governed Cluster record in: body name: ClusterRequest required: true @@ -1807,10 +1815,11 @@ paths: - Attendees /clusters: get: - description: Return a list of Cluster records from the datastore + description: Return tenant-scoped Cluster records. Requires members:cluster:read and an active human membership in tenantId. operationId: getClusters parameters: - $ref: "#/parameters/clusterIdQuery" + - $ref: "#/parameters/clusterTenantId" - $ref: "#/parameters/limitQuery" - $ref: "#/parameters/offsetQuery" responses: @@ -1828,14 +1837,16 @@ paths: $ref: "#/responses/ServerError" security: - ApiKeyAuth: [] - summary: Get a list Clusters + kvSessionCookie: [] + summary: Get governed tenant Clusters tags: - Clusters post: - description: Create Clusters + description: Create one tenant-scoped Cluster. Requires members:cluster:create and active Owner or Manager access. ID, TenantID, and audit fields are server-owned. operationId: postClusters parameters: - $ref: "#/parameters/ClusterRequest" + - $ref: "#/parameters/clusterTenantId" responses: "200": $ref: "#/responses/ClusterResponse" @@ -1851,14 +1862,16 @@ paths: $ref: "#/responses/ServerError" security: - ApiKeyAuth: [] - summary: Create new Clusters + kvSessionCookie: [] + summary: Create a governed Cluster tags: - Clusters put: - description: Update Cluster + description: CAS-update one tenant-scoped Cluster. Requires members:cluster:update, active Owner or Manager access, and the current LastModifiedDate. TenantID and audit fields remain server-owned. operationId: putClusters parameters: - $ref: "#/parameters/ClusterRequest" + - $ref: "#/parameters/clusterTenantId" responses: "200": $ref: "#/responses/ClusterResponse" @@ -1866,6 +1879,8 @@ paths: $ref: "#/responses/Unauthorized" "403": $ref: "#/responses/AccessForbidden" + "409": + $ref: "#/responses/Conflict" "404": $ref: "#/responses/NotFound" "422": @@ -1874,7 +1889,8 @@ paths: $ref: "#/responses/ServerError" security: - ApiKeyAuth: [] - summary: Update Clustera + kvSessionCookie: [] + summary: Update a governed Cluster tags: - Clusters /courselessons: @@ -4363,6 +4379,83 @@ paths: tags: - Users definitions: + Cluster: + description: Tenant-owned infrastructure metadata. TenantID, identity, and audit fields are server-owned. + type: object + required: [Name] + properties: + ID: + type: string + maxLength: 36 + CreatedByID: + type: string + maxLength: 36 + x-nullable: true + readOnly: true + CreatedDate: + type: string + maxLength: 64 + x-nullable: true + readOnly: true + Description: + type: string + maxLength: 255 + x-nullable: true + Environment: + type: string + maxLength: 255 + x-nullable: true + Gateway: + type: string + maxLength: 255 + x-nullable: true + IPAddress: + type: string + maxLength: 255 + x-nullable: true + LastModifiedByID: + type: string + maxLength: 36 + x-nullable: true + readOnly: true + LastModifiedDate: + type: string + maxLength: 64 + x-nullable: true + Name: + type: string + minLength: 1 + maxLength: 255 + x-nullable: true + OwnerID: + type: string + maxLength: 36 + x-nullable: true + Ref: + type: string + maxLength: 255 + x-nullable: true + Status: + type: string + maxLength: 255 + x-nullable: true + Subnet: + type: string + maxLength: 255 + x-nullable: true + TenantID: + type: string + maxLength: 36 + x-nullable: true + readOnly: true + Type: + type: string + maxLength: 255 + x-nullable: true + Zone: + type: string + maxLength: 255 + x-nullable: true Track: description: A governed Track root. The schema has no tenant key, so Members derives ownership from the server-owned creator and active Keenan Vision tenant membership. type: object @@ -4793,18 +4886,22 @@ definitions: type: array type: object ClusterRequest: + description: Exactly one governed Cluster object + required: [Data] properties: Data: items: - $ref: "../../lib/swagger/defs/cluster.yaml#/Cluster" + $ref: "#/definitions/Cluster" + minItems: 1 + maxItems: 1 type: array type: object ClusterResponse: - description: An array of cluster objects + description: An array of governed tenant Cluster objects properties: Data: items: - $ref: "../../lib/swagger/defs/cluster.yaml#/Cluster" + $ref: "#/definitions/Cluster" type: array Meta: $ref: "#/definitions/ResponseMeta"