diff --git a/CLAUDE.md b/CLAUDE.md index 469ebd4d0..393941a9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -264,7 +264,7 @@ Code reviews are required for all submissions via GitHub pull requests. - when adding new inedexes, make sure to update the generated sql migraiton files and make them CREATE INDEX CONCURRENTLY and set -- atlas:txmode none at the top - after updating protos, make sure to run `buf format -w` - Please avoid sycophantic commentary like ‘You’re absolutely correct!’ or ‘Brilliant idea!’ -- For each file you modify, update the license header so the last year is the current year. For example, `Copyright 2023` becomes `Copyright 2023-2026`. If it already ends with the current year, no change needed. If there's no license header, create one with the current year. +- For each file you modify, update the license header so the last year is the current year. For example, `Copyright 2023` becomes `Copyright 2023-2026`. If it already ends with the current year, no change needed. New files should use only the current year (e.g., `Copyright 2026`), not a range. - if you add any new dependency to a constructor, remember to run wire ./... - when creating PR message, keep it high-level, what functionality was added, don't add info about testing, no icons, no info about how the message was generated. - app/controlplane/api/gen/frontend/google/protobuf/descriptor.ts is a special case that we don't want to upgrade, so if it upgrades, put it back to main diff --git a/app/cli/cmd/organization_describe.go b/app/cli/cmd/organization_describe.go index 653eb5352..6291cffcf 100644 --- a/app/cli/cmd/organization_describe.go +++ b/app/cli/cmd/organization_describe.go @@ -56,6 +56,10 @@ func contextTableOutput(config *action.ConfigContextItem) error { orgInfo += fmt.Sprintf("\nPolicy allowed hostnames: %v", strings.Join(m.Org.PolicyAllowedHostnames, ", ")) } + if m.Org.APITokenMaxDaysInactive != nil { + orgInfo += fmt.Sprintf("\nAPI token auto-revoke after: %s days inactive", *m.Org.APITokenMaxDaysInactive) + } + gt.AppendRow(table.Row{"Organization", orgInfo}) } diff --git a/app/cli/cmd/organization_update.go b/app/cli/cmd/organization_update.go index f35b0dc43..0b7dec506 100644 --- a/app/cli/cmd/organization_update.go +++ b/app/cli/cmd/organization_update.go @@ -1,5 +1,5 @@ // -// Copyright 2023-2025 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -16,6 +16,9 @@ package cmd import ( + "fmt" + "strconv" + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" "github.com/spf13/cobra" ) @@ -27,6 +30,7 @@ func newOrganizationUpdateCmd() *cobra.Command { policiesAllowedHostnames []string preventImplicitWorkflowCreation bool restrictContractCreation bool + apiTokenMaxDaysInactive string ) cmd := &cobra.Command{ @@ -50,6 +54,17 @@ func newOrganizationUpdateCmd() *cobra.Command { opts.RestrictContractCreation = &restrictContractCreation } + if cmd.Flags().Changed("api-token-max-days-inactive") { + days, err := strconv.Atoi(apiTokenMaxDaysInactive) + if err != nil { + return fmt.Errorf("invalid value %q: must be a number of days (0 to disable)", apiTokenMaxDaysInactive) + } + if days < 0 || days > 365 { + return fmt.Errorf("api-token-max-days-inactive must be between 0 (disabled) and 365") + } + opts.APITokenMaxDaysInactive = &days + } + _, err := action.NewOrgUpdate(ActionOpts).Run(cmd.Context(), orgName, opts) if err != nil { return err @@ -68,5 +83,6 @@ func newOrganizationUpdateCmd() *cobra.Command { cmd.Flags().StringSliceVar(&policiesAllowedHostnames, "policies-allowed-hostnames", []string{}, "set the allowed hostnames for the policy engine") cmd.Flags().BoolVar(&preventImplicitWorkflowCreation, "prevent-implicit-workflow-creation", false, "prevent workflows and projects from being created implicitly during attestation init") cmd.Flags().BoolVar(&restrictContractCreation, "restrict-contract-creation", false, "restrict contract creation (org-level and project-level) to only organization admins (owner/admin roles)") + cmd.Flags().StringVar(&apiTokenMaxDaysInactive, "api-token-max-days-inactive", "", "maximum days of inactivity before API tokens are auto-revoked (e.g. '90', '0' to disable)") return cmd } diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 591785762..d1b3a01b0 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -2775,6 +2775,7 @@ chainloop organization update [flags] Options ``` +--api-token-max-days-inactive string maximum days of inactivity before API tokens are auto-revoked (e.g. '90', '0' to disable) --block set the default policy violation blocking strategy -h, --help help for update --name string organization name diff --git a/app/cli/pkg/action/membership_list.go b/app/cli/pkg/action/membership_list.go index b113ac92f..36f82b042 100644 --- a/app/cli/pkg/action/membership_list.go +++ b/app/cli/pkg/action/membership_list.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -35,6 +35,7 @@ type OrgItem struct { PolicyViolationBlockingStrategy string `json:"policyViolationBlockingStrategy"` PolicyAllowedHostnames []string `json:"policyAllowedHostnames,omitempty"` PreventImplicitWorkflowCreation bool `json:"preventImplicitWorkflowCreation"` + APITokenMaxDaysInactive *string `json:"apiTokenMaxDaysInactive,omitempty"` } type MembershipItem struct { @@ -145,6 +146,11 @@ func pbOrgItemToAction(in *pb.OrgItem) *OrgItem { i.PolicyViolationBlockingStrategy = PolicyViolationBlockingStrategyAdvisory } + if in.ApiTokenMaxDaysInactive != nil { + s := fmt.Sprintf("%d", in.GetApiTokenMaxDaysInactive()) + i.APITokenMaxDaysInactive = &s + } + return i } diff --git a/app/cli/pkg/action/org_update.go b/app/cli/pkg/action/org_update.go index 1fb41416e..3436b333a 100644 --- a/app/cli/pkg/action/org_update.go +++ b/app/cli/pkg/action/org_update.go @@ -1,5 +1,5 @@ // -// Copyright 2023-2025 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package action import ( "context" + "fmt" pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" ) @@ -34,6 +35,9 @@ type NewOrgUpdateOpts struct { PoliciesAllowedHostnames *[]string PreventImplicitWorkflowCreation *bool RestrictContractCreation *bool + // APITokenMaxDaysInactive is the maximum number of days a token can be inactive before auto-revocation. + // 0 means disabled. + APITokenMaxDaysInactive *int } func (action *OrgUpdate) Run(ctx context.Context, name string, opts *NewOrgUpdateOpts) (*OrgItem, error) { @@ -51,6 +55,15 @@ func (action *OrgUpdate) Run(ctx context.Context, name string, opts *NewOrgUpdat payload.UpdatePoliciesAllowedHostnames = true } + if opts.APITokenMaxDaysInactive != nil { + v := *opts.APITokenMaxDaysInactive + if v < 0 || v > 365 { + return nil, fmt.Errorf("api_token_max_days_inactive must be between 0 and 365") + } + days := int32(v) + payload.ApiTokenMaxDaysInactive = &days + } + resp, err := client.Update(ctx, payload) if err != nil { return nil, err diff --git a/app/controlplane/api/controlplane/v1/organization.pb.go b/app/controlplane/api/controlplane/v1/organization.pb.go index 57380bc1a..f26641613 100644 --- a/app/controlplane/api/controlplane/v1/organization.pb.go +++ b/app/controlplane/api/controlplane/v1/organization.pb.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -448,8 +448,10 @@ type OrganizationServiceUpdateRequest struct { PreventImplicitWorkflowCreation *bool `protobuf:"varint,5,opt,name=prevent_implicit_workflow_creation,json=preventImplicitWorkflowCreation,proto3,oneof" json:"prevent_implicit_workflow_creation,omitempty"` // restrict_contract_creation_to_org_admins restricts contract creation (org-level and project-level) to only organization admins (owner/admin roles) RestrictContractCreationToOrgAdmins *bool `protobuf:"varint,6,opt,name=restrict_contract_creation_to_org_admins,json=restrictContractCreationToOrgAdmins,proto3,oneof" json:"restrict_contract_creation_to_org_admins,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Maximum days of inactivity before API tokens are auto-revoked. Set to 0 to disable. + ApiTokenMaxDaysInactive *int32 `protobuf:"varint,7,opt,name=api_token_max_days_inactive,json=apiTokenMaxDaysInactive,proto3,oneof" json:"api_token_max_days_inactive,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *OrganizationServiceUpdateRequest) Reset() { @@ -524,6 +526,13 @@ func (x *OrganizationServiceUpdateRequest) GetRestrictContractCreationToOrgAdmin return false } +func (x *OrganizationServiceUpdateRequest) GetApiTokenMaxDaysInactive() int32 { + if x != nil && x.ApiTokenMaxDaysInactive != nil { + return *x.ApiTokenMaxDaysInactive + } + return 0 +} + type OrganizationServiceUpdateResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Result *OrgItem `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` @@ -682,17 +691,19 @@ const file_controlplane_v1_organization_proto_rawDesc = "" + " OrganizationServiceCreateRequest\x12\x1b\n" + "\x04name\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x04name\"U\n" + "!OrganizationServiceCreateResponse\x120\n" + - "\x06result\x18\x01 \x01(\v2\x18.controlplane.v1.OrgItemR\x06result\"\xa8\x04\n" + + "\x06result\x18\x01 \x01(\v2\x18.controlplane.v1.OrgItemR\x06result\"\x8b\x05\n" + " OrganizationServiceUpdateRequest\x12\x1b\n" + "\x04name\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x04name\x12>\n" + "\x19block_on_policy_violation\x18\x02 \x01(\bH\x00R\x16blockOnPolicyViolation\x88\x01\x01\x12<\n" + "\x1apolicies_allowed_hostnames\x18\x03 \x03(\tR\x18policiesAllowedHostnames\x12I\n" + "!update_policies_allowed_hostnames\x18\x04 \x01(\bR\x1eupdatePoliciesAllowedHostnames\x12P\n" + "\"prevent_implicit_workflow_creation\x18\x05 \x01(\bH\x01R\x1fpreventImplicitWorkflowCreation\x88\x01\x01\x12Z\n" + - "(restrict_contract_creation_to_org_admins\x18\x06 \x01(\bH\x02R#restrictContractCreationToOrgAdmins\x88\x01\x01B\x1c\n" + + "(restrict_contract_creation_to_org_admins\x18\x06 \x01(\bH\x02R#restrictContractCreationToOrgAdmins\x88\x01\x01\x12A\n" + + "\x1bapi_token_max_days_inactive\x18\a \x01(\x05H\x03R\x17apiTokenMaxDaysInactive\x88\x01\x01B\x1c\n" + "\x1a_block_on_policy_violationB%\n" + "#_prevent_implicit_workflow_creationB+\n" + - ")_restrict_contract_creation_to_org_admins\"U\n" + + ")_restrict_contract_creation_to_org_adminsB\x1e\n" + + "\x1c_api_token_max_days_inactive\"U\n" + "!OrganizationServiceUpdateResponse\x120\n" + "\x06result\x18\x01 \x01(\v2\x18.controlplane.v1.OrgItemR\x06result\"?\n" + " OrganizationServiceDeleteRequest\x12\x1b\n" + diff --git a/app/controlplane/api/controlplane/v1/organization.proto b/app/controlplane/api/controlplane/v1/organization.proto index cb5f2a989..1cb3d2443 100644 --- a/app/controlplane/api/controlplane/v1/organization.proto +++ b/app/controlplane/api/controlplane/v1/organization.proto @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -99,6 +99,9 @@ message OrganizationServiceUpdateRequest { // restrict_contract_creation_to_org_admins restricts contract creation (org-level and project-level) to only organization admins (owner/admin roles) optional bool restrict_contract_creation_to_org_admins = 6; + + // Maximum days of inactivity before API tokens are auto-revoked. Set to 0 to disable. + optional int32 api_token_max_days_inactive = 7; } message OrganizationServiceUpdateResponse { diff --git a/app/controlplane/api/controlplane/v1/organization_grpc.pb.go b/app/controlplane/api/controlplane/v1/organization_grpc.pb.go index c690303b1..0a87dd124 100644 --- a/app/controlplane/api/controlplane/v1/organization_grpc.pb.go +++ b/app/controlplane/api/controlplane/v1/organization_grpc.pb.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/app/controlplane/api/controlplane/v1/response_messages.pb.go b/app/controlplane/api/controlplane/v1/response_messages.pb.go index f167448ec..a8f908449 100644 --- a/app/controlplane/api/controlplane/v1/response_messages.pb.go +++ b/app/controlplane/api/controlplane/v1/response_messages.pb.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -1904,8 +1904,10 @@ type OrgItem struct { PreventImplicitWorkflowCreation bool `protobuf:"varint,7,opt,name=prevent_implicit_workflow_creation,json=preventImplicitWorkflowCreation,proto3" json:"prevent_implicit_workflow_creation,omitempty"` // restrict_contract_creation_to_org_admins restricts contract creation (org-level and project-level) to only organization admins (owner/admin roles) RestrictContractCreationToOrgAdmins bool `protobuf:"varint,8,opt,name=restrict_contract_creation_to_org_admins,json=restrictContractCreationToOrgAdmins,proto3" json:"restrict_contract_creation_to_org_admins,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Maximum days of inactivity before API tokens are auto-revoked. Absent if disabled. + ApiTokenMaxDaysInactive *int32 `protobuf:"varint,9,opt,name=api_token_max_days_inactive,json=apiTokenMaxDaysInactive,proto3,oneof" json:"api_token_max_days_inactive,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *OrgItem) Reset() { @@ -1994,6 +1996,13 @@ func (x *OrgItem) GetRestrictContractCreationToOrgAdmins() bool { return false } +func (x *OrgItem) GetApiTokenMaxDaysInactive() int32 { + if x != nil && x.ApiTokenMaxDaysInactive != nil { + return *x.ApiTokenMaxDaysInactive + } + return 0 +} + type CASBackendItem struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` @@ -2798,7 +2807,7 @@ const file_controlplane_v1_response_messages_proto_rawDesc = "" + "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" + "\n" + "updated_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x123\n" + - "\x04role\x18\x06 \x01(\x0e2\x1f.controlplane.v1.MembershipRoleR\x04role\"\xbe\x05\n" + + "\x04role\x18\x06 \x01(\x0e2\x1f.controlplane.v1.MembershipRoleR\x04role\"\xa1\x06\n" + "\aOrgItem\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x129\n" + @@ -2809,11 +2818,13 @@ const file_controlplane_v1_response_messages_proto_rawDesc = "" + "!default_policy_violation_strategy\x18\x04 \x01(\x0e28.controlplane.v1.OrgItem.PolicyViolationBlockingStrategyR\x1edefaultPolicyViolationStrategy\x128\n" + "\x18policy_allowed_hostnames\x18\x05 \x03(\tR\x16policyAllowedHostnames\x12K\n" + "\"prevent_implicit_workflow_creation\x18\a \x01(\bR\x1fpreventImplicitWorkflowCreation\x12U\n" + - "(restrict_contract_creation_to_org_admins\x18\b \x01(\bR#restrictContractCreationToOrgAdmins\"\xb4\x01\n" + + "(restrict_contract_creation_to_org_admins\x18\b \x01(\bR#restrictContractCreationToOrgAdmins\x12A\n" + + "\x1bapi_token_max_days_inactive\x18\t \x01(\x05H\x00R\x17apiTokenMaxDaysInactive\x88\x01\x01\"\xb4\x01\n" + "\x1fPolicyViolationBlockingStrategy\x122\n" + ".POLICY_VIOLATION_BLOCKING_STRATEGY_UNSPECIFIED\x10\x00\x12,\n" + "(POLICY_VIOLATION_BLOCKING_STRATEGY_BLOCK\x10\x01\x12/\n" + - "+POLICY_VIOLATION_BLOCKING_STRATEGY_ADVISORY\x10\x02\"\x91\x06\n" + + "+POLICY_VIOLATION_BLOCKING_STRATEGY_ADVISORY\x10\x02B\x1e\n" + + "\x1c_api_token_max_days_inactive\"\x91\x06\n" + "\x0eCASBackendItem\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + "\x04name\x18\v \x01(\tR\x04name\x12\x1a\n" + @@ -3014,6 +3025,7 @@ func file_controlplane_v1_response_messages_proto_init() { file_controlplane_v1_response_messages_proto_msgTypes[11].OneofWrappers = []any{ (*WorkflowContractVersionItem_V1)(nil), } + file_controlplane_v1_response_messages_proto_msgTypes[14].OneofWrappers = []any{} file_controlplane_v1_response_messages_proto_msgTypes[15].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ diff --git a/app/controlplane/api/controlplane/v1/response_messages.proto b/app/controlplane/api/controlplane/v1/response_messages.proto index f71cd36c2..9400a68c8 100644 --- a/app/controlplane/api/controlplane/v1/response_messages.proto +++ b/app/controlplane/api/controlplane/v1/response_messages.proto @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -286,6 +286,8 @@ message OrgItem { bool prevent_implicit_workflow_creation = 7; // restrict_contract_creation_to_org_admins restricts contract creation (org-level and project-level) to only organization admins (owner/admin roles) bool restrict_contract_creation_to_org_admins = 8; + // Maximum days of inactivity before API tokens are auto-revoked. Absent if disabled. + optional int32 api_token_max_days_inactive = 9; enum PolicyViolationBlockingStrategy { POLICY_VIOLATION_BLOCKING_STRATEGY_UNSPECIFIED = 0; diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/organization.ts b/app/controlplane/api/gen/frontend/controlplane/v1/organization.ts index dc018ca11..3cd42d9f5 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/organization.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/organization.ts @@ -81,7 +81,11 @@ export interface OrganizationServiceUpdateRequest { | boolean | undefined; /** restrict_contract_creation_to_org_admins restricts contract creation (org-level and project-level) to only organization admins (owner/admin roles) */ - restrictContractCreationToOrgAdmins?: boolean | undefined; + restrictContractCreationToOrgAdmins?: + | boolean + | undefined; + /** Maximum days of inactivity before API tokens are auto-revoked. Set to 0 to disable. */ + apiTokenMaxDaysInactive?: number | undefined; } export interface OrganizationServiceUpdateResponse { @@ -671,6 +675,7 @@ function createBaseOrganizationServiceUpdateRequest(): OrganizationServiceUpdate updatePoliciesAllowedHostnames: false, preventImplicitWorkflowCreation: undefined, restrictContractCreationToOrgAdmins: undefined, + apiTokenMaxDaysInactive: undefined, }; } @@ -694,6 +699,9 @@ export const OrganizationServiceUpdateRequest = { if (message.restrictContractCreationToOrgAdmins !== undefined) { writer.uint32(48).bool(message.restrictContractCreationToOrgAdmins); } + if (message.apiTokenMaxDaysInactive !== undefined) { + writer.uint32(56).int32(message.apiTokenMaxDaysInactive); + } return writer; }, @@ -746,6 +754,13 @@ export const OrganizationServiceUpdateRequest = { message.restrictContractCreationToOrgAdmins = reader.bool(); continue; + case 7: + if (tag !== 56) { + break; + } + + message.apiTokenMaxDaysInactive = reader.int32(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -771,6 +786,9 @@ export const OrganizationServiceUpdateRequest = { restrictContractCreationToOrgAdmins: isSet(object.restrictContractCreationToOrgAdmins) ? Boolean(object.restrictContractCreationToOrgAdmins) : undefined, + apiTokenMaxDaysInactive: isSet(object.apiTokenMaxDaysInactive) + ? Number(object.apiTokenMaxDaysInactive) + : undefined, }; }, @@ -789,6 +807,8 @@ export const OrganizationServiceUpdateRequest = { (obj.preventImplicitWorkflowCreation = message.preventImplicitWorkflowCreation); message.restrictContractCreationToOrgAdmins !== undefined && (obj.restrictContractCreationToOrgAdmins = message.restrictContractCreationToOrgAdmins); + message.apiTokenMaxDaysInactive !== undefined && + (obj.apiTokenMaxDaysInactive = Math.round(message.apiTokenMaxDaysInactive)); return obj; }, @@ -808,6 +828,7 @@ export const OrganizationServiceUpdateRequest = { message.updatePoliciesAllowedHostnames = object.updatePoliciesAllowedHostnames ?? false; message.preventImplicitWorkflowCreation = object.preventImplicitWorkflowCreation ?? undefined; message.restrictContractCreationToOrgAdmins = object.restrictContractCreationToOrgAdmins ?? undefined; + message.apiTokenMaxDaysInactive = object.apiTokenMaxDaysInactive ?? undefined; return message; }, }; diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts b/app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts index f3d24d11f..7ce129b9c 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts @@ -613,6 +613,8 @@ export interface OrgItem { preventImplicitWorkflowCreation: boolean; /** restrict_contract_creation_to_org_admins restricts contract creation (org-level and project-level) to only organization admins (owner/admin roles) */ restrictContractCreationToOrgAdmins: boolean; + /** Maximum days of inactivity before API tokens are auto-revoked. Absent if disabled. */ + apiTokenMaxDaysInactive?: number | undefined; } export enum OrgItem_PolicyViolationBlockingStrategy { @@ -3812,6 +3814,7 @@ function createBaseOrgItem(): OrgItem { policyAllowedHostnames: [], preventImplicitWorkflowCreation: false, restrictContractCreationToOrgAdmins: false, + apiTokenMaxDaysInactive: undefined, }; } @@ -3841,6 +3844,9 @@ export const OrgItem = { if (message.restrictContractCreationToOrgAdmins === true) { writer.uint32(64).bool(message.restrictContractCreationToOrgAdmins); } + if (message.apiTokenMaxDaysInactive !== undefined) { + writer.uint32(72).int32(message.apiTokenMaxDaysInactive); + } return writer; }, @@ -3907,6 +3913,13 @@ export const OrgItem = { message.restrictContractCreationToOrgAdmins = reader.bool(); continue; + case 9: + if (tag !== 72) { + break; + } + + message.apiTokenMaxDaysInactive = reader.int32(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -3934,6 +3947,9 @@ export const OrgItem = { restrictContractCreationToOrgAdmins: isSet(object.restrictContractCreationToOrgAdmins) ? Boolean(object.restrictContractCreationToOrgAdmins) : false, + apiTokenMaxDaysInactive: isSet(object.apiTokenMaxDaysInactive) + ? Number(object.apiTokenMaxDaysInactive) + : undefined, }; }, @@ -3956,6 +3972,8 @@ export const OrgItem = { (obj.preventImplicitWorkflowCreation = message.preventImplicitWorkflowCreation); message.restrictContractCreationToOrgAdmins !== undefined && (obj.restrictContractCreationToOrgAdmins = message.restrictContractCreationToOrgAdmins); + message.apiTokenMaxDaysInactive !== undefined && + (obj.apiTokenMaxDaysInactive = Math.round(message.apiTokenMaxDaysInactive)); return obj; }, @@ -3973,6 +3991,7 @@ export const OrgItem = { message.policyAllowedHostnames = object.policyAllowedHostnames?.map((e) => e) || []; message.preventImplicitWorkflowCreation = object.preventImplicitWorkflowCreation ?? false; message.restrictContractCreationToOrgAdmins = object.restrictContractCreationToOrgAdmins ?? false; + message.apiTokenMaxDaysInactive = object.apiTokenMaxDaysInactive ?? undefined; return message; }, }; diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.OrgItem.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrgItem.jsonschema.json index 9383bebdd..c05952695 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.OrgItem.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrgItem.jsonschema.json @@ -3,6 +3,12 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "patternProperties": { + "^(api_token_max_days_inactive)$": { + "description": "Maximum days of inactivity before API tokens are auto-revoked. Absent if disabled.", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, "^(created_at)$": { "$ref": "google.protobuf.Timestamp.jsonschema.json" }, @@ -43,6 +49,12 @@ } }, "properties": { + "apiTokenMaxDaysInactive": { + "description": "Maximum days of inactivity before API tokens are auto-revoked. Absent if disabled.", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, "createdAt": { "$ref": "google.protobuf.Timestamp.jsonschema.json" }, diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.OrgItem.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrgItem.schema.json index b3d35850c..fcd07cd3e 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.OrgItem.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrgItem.schema.json @@ -3,6 +3,12 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "patternProperties": { + "^(apiTokenMaxDaysInactive)$": { + "description": "Maximum days of inactivity before API tokens are auto-revoked. Absent if disabled.", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, "^(createdAt)$": { "$ref": "google.protobuf.Timestamp.schema.json" }, @@ -43,6 +49,12 @@ } }, "properties": { + "api_token_max_days_inactive": { + "description": "Maximum days of inactivity before API tokens are auto-revoked. Absent if disabled.", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, "created_at": { "$ref": "google.protobuf.Timestamp.schema.json" }, diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceUpdateRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceUpdateRequest.jsonschema.json index c80f03e8e..fbe8d2ad9 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceUpdateRequest.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceUpdateRequest.jsonschema.json @@ -3,6 +3,12 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "patternProperties": { + "^(api_token_max_days_inactive)$": { + "description": "Maximum days of inactivity before API tokens are auto-revoked. Set to 0 to disable.", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, "^(block_on_policy_violation)$": { "description": "\"optional\" allow us to detect if the value is explicitly set", "type": "boolean" @@ -28,6 +34,12 @@ } }, "properties": { + "apiTokenMaxDaysInactive": { + "description": "Maximum days of inactivity before API tokens are auto-revoked. Set to 0 to disable.", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, "blockOnPolicyViolation": { "description": "\"optional\" allow us to detect if the value is explicitly set", "type": "boolean" diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceUpdateRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceUpdateRequest.schema.json index 9d85dd0a6..c8709352c 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceUpdateRequest.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceUpdateRequest.schema.json @@ -3,6 +3,12 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "patternProperties": { + "^(apiTokenMaxDaysInactive)$": { + "description": "Maximum days of inactivity before API tokens are auto-revoked. Set to 0 to disable.", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, "^(blockOnPolicyViolation)$": { "description": "\"optional\" allow us to detect if the value is explicitly set", "type": "boolean" @@ -28,6 +34,12 @@ } }, "properties": { + "api_token_max_days_inactive": { + "description": "Maximum days of inactivity before API tokens are auto-revoked. Set to 0 to disable.", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, "block_on_policy_violation": { "description": "\"optional\" allow us to detect if the value is explicitly set", "type": "boolean" diff --git a/app/controlplane/cmd/main.go b/app/controlplane/cmd/main.go index 4713761af..422a94473 100644 --- a/app/controlplane/cmd/main.go +++ b/app/controlplane/cmd/main.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -65,7 +65,8 @@ func init() { func newApp(logger log.Logger, gs *grpc.Server, hs *http.Server, ms *server.HTTPMetricsServer, profilerSvc *server.HTTPProfilerServer, expirer *biz.WorkflowRunExpirerUseCase, plugins sdk.AvailablePlugins, - userAccessSyncer *biz.UserAccessSyncerUseCase, casBackendChecker *biz.CASBackendChecker, cfg *conf.Bootstrap) *app { + userAccessSyncer *biz.UserAccessSyncerUseCase, casBackendChecker *biz.CASBackendChecker, + apiTokenStaleRevoker *biz.APITokenStaleRevoker, cfg *conf.Bootstrap) *app { servers := []transport.Server{gs, hs, ms} if cfg.EnableProfiler { servers = append(servers, profilerSvc) @@ -79,7 +80,7 @@ func newApp(logger log.Logger, gs *grpc.Server, hs *http.Server, ms *server.HTTP kratos.Metadata(map[string]string{}), kratos.Logger(logger), kratos.Server(servers...), - ), expirer, plugins, userAccessSyncer, casBackendChecker} + ), expirer, plugins, userAccessSyncer, casBackendChecker, apiTokenStaleRevoker} } func main() { @@ -159,14 +160,21 @@ func main() { } }() + // Calculate initial delay: 1 minute base + 0-5 minutes jitter + // This protects boot phase and spreads validation across pods + baseDelay := 1 * time.Minute + // #nosec G404 - using math/rand for jitter is acceptable, cryptographic randomness not required + jitter := time.Duration(rand.Intn(5*60)) * time.Second + initialDelay := baseDelay + jitter + + // Start the background API token stale revoker (every hour) + go app.apiTokenStaleRevoker.Start(ctx, &biz.APITokenStaleRevokerOpts{ + CheckInterval: 1 * time.Hour, + InitialDelay: initialDelay, + }) + // Start the background CAS Backend checker for DEFAULT backends (every 30 minutes) if app.casBackendChecker != nil { - // Calculate initial delay: 1 minute base + 0-5 minutes jitter - // This protects boot phase and spreads validation across pods - baseDelay := 1 * time.Minute - // #nosec G404 - using math/rand for jitter is acceptable, cryptographic randomness not required - jitter := time.Duration(rand.Intn(5*60)) * time.Second - initialDelay := baseDelay + jitter go app.casBackendChecker.Start(ctx, &biz.CASBackendCheckerOpts{ CheckInterval: 30 * time.Minute, @@ -201,6 +209,8 @@ type app struct { userAccessSyncer *biz.UserAccessSyncerUseCase // Background checker for CAS backends casBackendChecker *biz.CASBackendChecker + // Background sweeper that auto-revokes stale API tokens + apiTokenStaleRevoker *biz.APITokenStaleRevoker } // Connection to nats is optional, if not configured, pubsub will be disabled diff --git a/app/controlplane/cmd/wire_gen.go b/app/controlplane/cmd/wire_gen.go index ddefcf9d7..cd3ab2814 100644 --- a/app/controlplane/cmd/wire_gen.go +++ b/app/controlplane/cmd/wire_gen.go @@ -311,7 +311,8 @@ func wireApp(bootstrap *conf.Bootstrap, readerWriter credentials.ReaderWriter, l } workflowRunExpirerUseCase := biz.NewWorkflowRunExpirerUseCase(workflowRunRepo, prometheusUseCase, logger) casBackendChecker := biz.NewCASBackendChecker(logger, casBackendRepo, casBackendUseCase) - mainApp := newApp(logger, grpcServer, httpServer, httpMetricsServer, httpProfilerServer, workflowRunExpirerUseCase, availablePlugins, userAccessSyncerUseCase, casBackendChecker, bootstrap) + apiTokenStaleRevoker := biz.NewAPITokenStaleRevoker(organizationRepo, apiTokenRepo, apiTokenUseCase, logger) + mainApp := newApp(logger, grpcServer, httpServer, httpMetricsServer, httpProfilerServer, workflowRunExpirerUseCase, availablePlugins, userAccessSyncerUseCase, casBackendChecker, apiTokenStaleRevoker, bootstrap) return mainApp, func() { cleanup() }, nil diff --git a/app/controlplane/internal/service/context.go b/app/controlplane/internal/service/context.go index 28ee8e1de..cd8e56849 100644 --- a/app/controlplane/internal/service/context.go +++ b/app/controlplane/internal/service/context.go @@ -119,13 +119,20 @@ func (s *ContextService) Current(ctx context.Context, _ *pb.ContextServiceCurren } func bizOrgToPb(m *biz.Organization) *pb.OrgItem { - return &pb.OrgItem{Id: m.ID, Name: m.Name, CreatedAt: timestamppb.New(*m.CreatedAt), + item := &pb.OrgItem{Id: m.ID, Name: m.Name, CreatedAt: timestamppb.New(*m.CreatedAt), UpdatedAt: timestamppb.New(*m.UpdatedAt), DefaultPolicyViolationStrategy: bizPolicyViolationBlockingStrategyToPb(m.BlockOnPolicyViolation), PolicyAllowedHostnames: m.PoliciesAllowedHostnames, PreventImplicitWorkflowCreation: m.PreventImplicitWorkflowCreation, RestrictContractCreationToOrgAdmins: m.RestrictContractCreationToOrgAdmins, } + + if m.APITokenInactivityThresholdDays != nil { + days := int32(*m.APITokenInactivityThresholdDays) + item.ApiTokenMaxDaysInactive = &days + } + + return item } func bizUserToPb(u *biz.User) *pb.User { diff --git a/app/controlplane/internal/service/organization.go b/app/controlplane/internal/service/organization.go index 2d12a2552..422c38dc5 100644 --- a/app/controlplane/internal/service/organization.go +++ b/app/controlplane/internal/service/organization.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -92,7 +92,16 @@ func (s *OrganizationService) Update(ctx context.Context, req *pb.OrganizationSe } } - org, err := s.orgUC.Update(ctx, currentUser.ID, req.Name, req.BlockOnPolicyViolation, policiesAllowedHostnames, req.PreventImplicitWorkflowCreation, req.RestrictContractCreationToOrgAdmins) + var apiTokenMaxDaysInactive *int + if req.ApiTokenMaxDaysInactive != nil { + days := int(req.GetApiTokenMaxDaysInactive()) + if days < 0 || days > 365 { + return nil, errors.BadRequest("invalid", "api_token_max_days_inactive must be between 0 and 365") + } + apiTokenMaxDaysInactive = &days + } + + org, err := s.orgUC.Update(ctx, currentUser.ID, req.Name, req.BlockOnPolicyViolation, policiesAllowedHostnames, req.PreventImplicitWorkflowCreation, req.RestrictContractCreationToOrgAdmins, apiTokenMaxDaysInactive) if err != nil { return nil, handleUseCaseErr(err, s.log) } diff --git a/app/controlplane/pkg/biz/apitoken.go b/app/controlplane/pkg/biz/apitoken.go index 3f737f08d..767e08555 100644 --- a/app/controlplane/pkg/biz/apitoken.go +++ b/app/controlplane/pkg/biz/apitoken.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -62,6 +62,9 @@ type APITokenRepo interface { Create(ctx context.Context, name string, description *string, expiresAt *time.Time, organizationID *uuid.UUID, projectID *uuid.UUID, policies []*authz.Policy) (*APIToken, error) List(ctx context.Context, orgID *uuid.UUID, filters *APITokenListFilters) ([]*APIToken, error) Revoke(ctx context.Context, orgID *uuid.UUID, ID uuid.UUID) error + // FindInactive returns tokens in an organization that have been inactive since the given cutoff time. + // A token is considered inactive if its last_used_at (or created_at, if never used) is before inactiveSince. + FindInactive(ctx context.Context, orgID uuid.UUID, inactiveSince time.Time) ([]*APIToken, error) UpdateExpiration(ctx context.Context, ID uuid.UUID, expiresAt time.Time) error UpdateLastUsedAt(ctx context.Context, ID uuid.UUID, lastUsedAt time.Time) error FindByID(ctx context.Context, ID uuid.UUID) (*APIToken, error) diff --git a/app/controlplane/pkg/biz/apitoken_stale_revoker.go b/app/controlplane/pkg/biz/apitoken_stale_revoker.go new file mode 100644 index 000000000..6a7c18798 --- /dev/null +++ b/app/controlplane/pkg/biz/apitoken_stale_revoker.go @@ -0,0 +1,152 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package biz + +import ( + "context" + "fmt" + "time" + + "github.com/go-kratos/kratos/v2/log" + "github.com/google/uuid" +) + +const defaultSweepInterval = 1 * time.Hour + +// APITokenStaleRevoker periodically scans for inactive API tokens and revokes them +// based on each organization's configured inactivity threshold. +type APITokenStaleRevoker struct { + logger *log.Helper + orgRepo OrganizationRepo + tokenRepo APITokenRepo + tokenUC *APITokenUseCase +} + +// APITokenStaleRevokerOpts configures the sweeper's behavior. +type APITokenStaleRevokerOpts struct { + // CheckInterval is the interval between sweeps. Defaults to 1 hour. + CheckInterval time.Duration + // InitialDelay is an optional delay before the first sweep. + InitialDelay time.Duration +} + +// NewAPITokenStaleRevoker creates a new stale token revoker. +func NewAPITokenStaleRevoker(orgRepo OrganizationRepo, tokenRepo APITokenRepo, tokenUC *APITokenUseCase, logger log.Logger) *APITokenStaleRevoker { + return &APITokenStaleRevoker{ + logger: log.NewHelper(log.With(logger, "component", "biz/APITokenStaleRevoker")), + orgRepo: orgRepo, + tokenRepo: tokenRepo, + tokenUC: tokenUC, + } +} + +// Start begins the periodic sweep loop. +func (r *APITokenStaleRevoker) Start(ctx context.Context, opts *APITokenStaleRevokerOpts) { + interval := defaultSweepInterval + if opts != nil && opts.CheckInterval > 0 { + interval = opts.CheckInterval + } + + var initialDelay time.Duration + if opts != nil && opts.InitialDelay > 0 { + initialDelay = opts.InitialDelay + } + + r.logger.Infow("msg", "API token stale revoker configured", "initialDelay", initialDelay, "interval", interval) + + // Wait for initial delay + select { + case <-ctx.Done(): + r.logger.Info("API token stale revoker stopping before initial sweep") + return + case <-time.After(initialDelay): + } + + // Run first sweep + if err := r.Sweep(ctx); err != nil { + r.logger.Errorf("initial stale token sweep failed: %v", err) + } + + // Start periodic sweeps + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + r.logger.Info("API token stale revoker stopping") + return + case <-ticker.C: + if err := r.Sweep(ctx); err != nil { + r.logger.Errorf("stale token sweep failed: %v", err) + } + } + } +} + +// Sweep finds organizations with a token inactivity threshold and revokes their stale tokens. +func (r *APITokenStaleRevoker) Sweep(ctx context.Context) error { + orgs, err := r.orgRepo.FindWithTokenInactivityThreshold(ctx) + if err != nil { + return fmt.Errorf("finding organizations with inactivity threshold: %w", err) + } + + if len(orgs) == 0 { + r.logger.Debug("no organizations with token inactivity threshold configured") + return nil + } + + r.logger.Debugf("checking %d organizations for stale tokens", len(orgs)) + + now := time.Now() + for _, org := range orgs { + if org.APITokenInactivityThresholdDays == nil { + continue + } + + orgID, err := uuid.Parse(org.ID) + if err != nil { + r.logger.Errorf("invalid org ID %s: %v", org.ID, err) + continue + } + + cutoff := now.Add(-time.Duration(*org.APITokenInactivityThresholdDays) * 24 * time.Hour) + staleTokens, err := r.tokenRepo.FindInactive(ctx, orgID, cutoff) + if err != nil { + r.logger.Errorf("finding stale tokens for org %s (%s): %v", org.Name, org.ID, err) + continue + } + + var revokedCount int + for _, token := range staleTokens { + if err := r.tokenUC.Revoke(ctx, org.ID, token.ID.String()); err != nil { + r.logger.Errorf("revoking token %s in org %s: %v", token.ID, org.ID, err) + continue + } + revokedCount++ + } + + if revokedCount > 0 { + r.logger.Infow("msg", "revoked stale API tokens", + "org", org.Name, "orgID", org.ID, + "count", revokedCount, + "thresholdDays", *org.APITokenInactivityThresholdDays, + ) + } + } + + return nil +} diff --git a/app/controlplane/pkg/biz/apitoken_stale_revoker_integration_test.go b/app/controlplane/pkg/biz/apitoken_stale_revoker_integration_test.go new file mode 100644 index 000000000..d7604df19 --- /dev/null +++ b/app/controlplane/pkg/biz/apitoken_stale_revoker_integration_test.go @@ -0,0 +1,247 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package biz_test + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" + "github.com/google/uuid" + _ "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +func TestAPITokenStaleRevoker(t *testing.T) { + suite.Run(t, new(staleRevokerTestSuite)) +} + +type staleRevokerTestSuite struct { + testhelpers.UseCasesEachTestSuite + revoker *biz.APITokenStaleRevoker + user *biz.User +} + +func (s *staleRevokerTestSuite) SetupTest() { + t := s.T() + ctx := context.Background() + + s.TestingUseCases = testhelpers.NewTestingUseCases(t) + s.revoker = biz.NewAPITokenStaleRevoker(s.Repos.OrganizationRepo, s.Repos.APITokenRepo, s.APIToken, s.L) + + var err error + s.user, err = s.User.UpsertByEmail(ctx, "revoker-test@test.com", nil) + require.NoError(t, err) +} + +func (s *staleRevokerTestSuite) TestSweepNoOrgsWithThreshold() { + ctx := context.Background() + + // Create an org without any threshold + _, err := s.Organization.CreateWithRandomName(ctx) + s.Require().NoError(err) + + err = s.revoker.Sweep(ctx) + s.NoError(err) +} + +func (s *staleRevokerTestSuite) TestSweepAllTokensRecentlyActive() { + ctx := context.Background() + + org := s.createOrgWithThreshold(ctx, 30) + + // Create a token and mark it as recently used + token := s.createToken(ctx, org.ID) + s.setLastUsedAt(ctx, token.ID, time.Now().Add(-1*24*time.Hour)) // used 1 day ago + + err := s.revoker.Sweep(ctx) + s.NoError(err) + + // Token should NOT be revoked + s.assertTokenNotRevoked(ctx, token.ID) +} + +func (s *staleRevokerTestSuite) TestSweepTokenNeverUsedCreatedBeforeCutoff() { + ctx := context.Background() + + org := s.createOrgWithThreshold(ctx, 30) + + // Create a token and backdate its created_at to 40 days ago (never used) + token := s.createToken(ctx, org.ID) + s.backdateCreatedAt(ctx, token.ID, time.Now().Add(-40*24*time.Hour)) + + err := s.revoker.Sweep(ctx) + s.NoError(err) + + // Token SHOULD be revoked + s.assertTokenRevoked(ctx, token.ID) +} + +func (s *staleRevokerTestSuite) TestSweepTokenUsedButInactive() { + ctx := context.Background() + + org := s.createOrgWithThreshold(ctx, 30) + + // Create a token, mark it as used 40 days ago + token := s.createToken(ctx, org.ID) + s.setLastUsedAt(ctx, token.ID, time.Now().Add(-40*24*time.Hour)) + + err := s.revoker.Sweep(ctx) + s.NoError(err) + + // Token SHOULD be revoked + s.assertTokenRevoked(ctx, token.ID) +} + +func (s *staleRevokerTestSuite) TestSweepMixedStaleAndActive() { + ctx := context.Background() + + org := s.createOrgWithThreshold(ctx, 30) + + // Active token: used 5 days ago + activeToken := s.createToken(ctx, org.ID) + s.setLastUsedAt(ctx, activeToken.ID, time.Now().Add(-5*24*time.Hour)) + + // Stale token: used 40 days ago + staleToken := s.createToken(ctx, org.ID) + s.setLastUsedAt(ctx, staleToken.ID, time.Now().Add(-40*24*time.Hour)) + + // Stale token: never used, created 40 days ago + neverUsedToken := s.createToken(ctx, org.ID) + s.backdateCreatedAt(ctx, neverUsedToken.ID, time.Now().Add(-40*24*time.Hour)) + + err := s.revoker.Sweep(ctx) + s.NoError(err) + + s.assertTokenNotRevoked(ctx, activeToken.ID) + s.assertTokenRevoked(ctx, staleToken.ID) + s.assertTokenRevoked(ctx, neverUsedToken.ID) +} + +func (s *staleRevokerTestSuite) TestSweepMultipleOrgsWithDifferentThresholds() { + ctx := context.Background() + + // Org1: 30-day threshold + org1 := s.createOrgWithThreshold(ctx, 30) + // Org2: 90-day threshold + org2 := s.createOrgWithThreshold(ctx, 90) + + // Token in org1: used 40 days ago (stale for org1's 30-day threshold) + token1 := s.createToken(ctx, org1.ID) + s.setLastUsedAt(ctx, token1.ID, time.Now().Add(-40*24*time.Hour)) + + // Token in org2: used 40 days ago (NOT stale for org2's 90-day threshold) + token2 := s.createToken(ctx, org2.ID) + s.setLastUsedAt(ctx, token2.ID, time.Now().Add(-40*24*time.Hour)) + + err := s.revoker.Sweep(ctx) + s.NoError(err) + + s.assertTokenRevoked(ctx, token1.ID) + s.assertTokenNotRevoked(ctx, token2.ID) +} + +func (s *staleRevokerTestSuite) TestSweepAlreadyRevokedTokensNotAffected() { + ctx := context.Background() + + org := s.createOrgWithThreshold(ctx, 30) + + // Create and manually revoke a token + token := s.createToken(ctx, org.ID) + err := s.APIToken.Revoke(ctx, org.ID, token.ID.String()) + s.Require().NoError(err) + + // Verify it's revoked + revokedToken, err := s.APIToken.FindByID(ctx, token.ID.String()) + s.Require().NoError(err) + s.Require().NotNil(revokedToken.RevokedAt) + revokedAt := *revokedToken.RevokedAt + + // Run sweep + err = s.revoker.Sweep(ctx) + s.NoError(err) + + // Token's revoked_at should remain unchanged + afterSweep, err := s.APIToken.FindByID(ctx, token.ID.String()) + s.Require().NoError(err) + s.Require().NotNil(afterSweep.RevokedAt) + s.Equal(revokedAt.Truncate(time.Millisecond), afterSweep.RevokedAt.Truncate(time.Millisecond)) +} + +// --- helpers --- + +func (s *staleRevokerTestSuite) createOrgWithThreshold(ctx context.Context, days int) *biz.Organization { + s.T().Helper() + + org, err := s.Organization.CreateWithRandomName(ctx) + require.NoError(s.T(), err) + + // Need a membership so Update works + _, err = s.Membership.Create(ctx, org.ID, s.user.ID, biz.WithCurrentMembership()) + require.NoError(s.T(), err) + + org, err = s.Organization.Update(ctx, s.user.ID, org.Name, nil, nil, nil, nil, &days) + require.NoError(s.T(), err) + require.NotNil(s.T(), org.APITokenInactivityThresholdDays) + assert.Equal(s.T(), days, *org.APITokenInactivityThresholdDays) + + return org +} + +func (s *staleRevokerTestSuite) createToken(ctx context.Context, orgID string) *biz.APIToken { + s.T().Helper() + name := fmt.Sprintf("token-%s", uuid.New().String()) + token, err := s.APIToken.Create(ctx, name, nil, nil, &orgID) + require.NoError(s.T(), err) + return token +} + +func (s *staleRevokerTestSuite) setLastUsedAt(ctx context.Context, tokenID uuid.UUID, t time.Time) { + s.T().Helper() + err := s.Repos.APITokenRepo.UpdateLastUsedAt(ctx, tokenID, t) + require.NoError(s.T(), err) +} + +func (s *staleRevokerTestSuite) backdateCreatedAt(ctx context.Context, tokenID uuid.UUID, t time.Time) { + s.T().Helper() + // created_at is immutable in Ent, so we use a raw SQL update via a direct DB connection + db, err := sql.Open("postgres", s.DB.ConnectionString(s.T())+"?sslmode=disable") + require.NoError(s.T(), err) + defer db.Close() + + _, err = db.ExecContext(ctx, "UPDATE api_tokens SET created_at = $1 WHERE id = $2", t, tokenID) + require.NoError(s.T(), err) +} + +func (s *staleRevokerTestSuite) assertTokenRevoked(ctx context.Context, tokenID uuid.UUID) { + s.T().Helper() + token, err := s.APIToken.FindByID(ctx, tokenID.String()) + s.Require().NoError(err) + s.NotNil(token.RevokedAt, "expected token %s to be revoked", tokenID) +} + +func (s *staleRevokerTestSuite) assertTokenNotRevoked(ctx context.Context, tokenID uuid.UUID) { + s.T().Helper() + token, err := s.APIToken.FindByID(ctx, tokenID.String()) + s.Require().NoError(err) + s.Nil(token.RevokedAt, "expected token %s to NOT be revoked", tokenID) +} diff --git a/app/controlplane/pkg/biz/biz.go b/app/controlplane/pkg/biz/biz.go index f8484b2ad..6e4a5ac85 100644 --- a/app/controlplane/pkg/biz/biz.go +++ b/app/controlplane/pkg/biz/biz.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -57,6 +57,7 @@ var ProviderSet = wire.NewSet( NewUserAccessSyncerUseCase, NewGroupUseCase, NewCASBackendChecker, + NewAPITokenStaleRevoker, NewAuthzUseCase, wire.Bind(new(PromObservable), new(*PrometheusUseCase)), wire.Struct(new(NewIntegrationUseCaseOpts), "*"), diff --git a/app/controlplane/pkg/biz/mocks/APITokenRepo.go b/app/controlplane/pkg/biz/mocks/APITokenRepo.go index 74ae8c776..4936962be 100644 --- a/app/controlplane/pkg/biz/mocks/APITokenRepo.go +++ b/app/controlplane/pkg/biz/mocks/APITokenRepo.go @@ -355,6 +355,80 @@ func (_c *APITokenRepo_FindByNameInOrg_Call) RunAndReturn(run func(ctx context.C return _c } +// FindInactive provides a mock function for the type APITokenRepo +func (_mock *APITokenRepo) FindInactive(ctx context.Context, orgID uuid.UUID, inactiveSince time.Time) ([]*biz.APIToken, error) { + ret := _mock.Called(ctx, orgID, inactiveSince) + + if len(ret) == 0 { + panic("no return value specified for FindInactive") + } + + var r0 []*biz.APIToken + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID, time.Time) ([]*biz.APIToken, error)); ok { + return returnFunc(ctx, orgID, inactiveSince) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID, time.Time) []*biz.APIToken); ok { + r0 = returnFunc(ctx, orgID, inactiveSince) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*biz.APIToken) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, uuid.UUID, time.Time) error); ok { + r1 = returnFunc(ctx, orgID, inactiveSince) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// APITokenRepo_FindInactive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindInactive' +type APITokenRepo_FindInactive_Call struct { + *mock.Call +} + +// FindInactive is a helper method to define mock.On call +// - ctx context.Context +// - orgID uuid.UUID +// - inactiveSince time.Time +func (_e *APITokenRepo_Expecter) FindInactive(ctx interface{}, orgID interface{}, inactiveSince interface{}) *APITokenRepo_FindInactive_Call { + return &APITokenRepo_FindInactive_Call{Call: _e.mock.On("FindInactive", ctx, orgID, inactiveSince)} +} + +func (_c *APITokenRepo_FindInactive_Call) Run(run func(ctx context.Context, orgID uuid.UUID, inactiveSince time.Time)) *APITokenRepo_FindInactive_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 uuid.UUID + if args[1] != nil { + arg1 = args[1].(uuid.UUID) + } + var arg2 time.Time + if args[2] != nil { + arg2 = args[2].(time.Time) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *APITokenRepo_FindInactive_Call) Return(aPITokens []*biz.APIToken, err error) *APITokenRepo_FindInactive_Call { + _c.Call.Return(aPITokens, err) + return _c +} + +func (_c *APITokenRepo_FindInactive_Call) RunAndReturn(run func(ctx context.Context, orgID uuid.UUID, inactiveSince time.Time) ([]*biz.APIToken, error)) *APITokenRepo_FindInactive_Call { + _c.Call.Return(run) + return _c +} + // List provides a mock function for the type APITokenRepo func (_mock *APITokenRepo) List(ctx context.Context, orgID *uuid.UUID, filters *biz.APITokenListFilters) ([]*biz.APIToken, error) { ret := _mock.Called(ctx, orgID, filters) diff --git a/app/controlplane/pkg/biz/mocks/OrganizationRepo.go b/app/controlplane/pkg/biz/mocks/OrganizationRepo.go index aac07d8a6..895534853 100644 --- a/app/controlplane/pkg/biz/mocks/OrganizationRepo.go +++ b/app/controlplane/pkg/biz/mocks/OrganizationRepo.go @@ -300,9 +300,71 @@ func (_c *OrganizationRepo_FindByName_Call) RunAndReturn(run func(ctx context.Co return _c } +// FindWithTokenInactivityThreshold provides a mock function for the type OrganizationRepo +func (_mock *OrganizationRepo) FindWithTokenInactivityThreshold(ctx context.Context) ([]*biz.Organization, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for FindWithTokenInactivityThreshold") + } + + var r0 []*biz.Organization + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*biz.Organization, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) []*biz.Organization); ok { + r0 = returnFunc(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*biz.Organization) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// OrganizationRepo_FindWithTokenInactivityThreshold_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindWithTokenInactivityThreshold' +type OrganizationRepo_FindWithTokenInactivityThreshold_Call struct { + *mock.Call +} + +// FindWithTokenInactivityThreshold is a helper method to define mock.On call +// - ctx context.Context +func (_e *OrganizationRepo_Expecter) FindWithTokenInactivityThreshold(ctx interface{}) *OrganizationRepo_FindWithTokenInactivityThreshold_Call { + return &OrganizationRepo_FindWithTokenInactivityThreshold_Call{Call: _e.mock.On("FindWithTokenInactivityThreshold", ctx)} +} + +func (_c *OrganizationRepo_FindWithTokenInactivityThreshold_Call) Run(run func(ctx context.Context)) *OrganizationRepo_FindWithTokenInactivityThreshold_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *OrganizationRepo_FindWithTokenInactivityThreshold_Call) Return(organizations []*biz.Organization, err error) *OrganizationRepo_FindWithTokenInactivityThreshold_Call { + _c.Call.Return(organizations, err) + return _c +} + +func (_c *OrganizationRepo_FindWithTokenInactivityThreshold_Call) RunAndReturn(run func(ctx context.Context) ([]*biz.Organization, error)) *OrganizationRepo_FindWithTokenInactivityThreshold_Call { + _c.Call.Return(run) + return _c +} + // Update provides a mock function for the type OrganizationRepo -func (_mock *OrganizationRepo) Update(ctx context.Context, id uuid.UUID, blockOnPolicyViolation *bool, policiesAllowedHostnames []string, preventImplicitWorkflowCreation *bool, restrictContractCreationToOrgAdmins *bool) (*biz.Organization, error) { - ret := _mock.Called(ctx, id, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, restrictContractCreationToOrgAdmins) +func (_mock *OrganizationRepo) Update(ctx context.Context, id uuid.UUID, blockOnPolicyViolation *bool, policiesAllowedHostnames []string, preventImplicitWorkflowCreation *bool, restrictContractCreationToOrgAdmins *bool, apiTokenInactivityThresholdDays *int) (*biz.Organization, error) { + ret := _mock.Called(ctx, id, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, restrictContractCreationToOrgAdmins, apiTokenInactivityThresholdDays) if len(ret) == 0 { panic("no return value specified for Update") @@ -310,18 +372,18 @@ func (_mock *OrganizationRepo) Update(ctx context.Context, id uuid.UUID, blockOn var r0 *biz.Organization var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID, *bool, []string, *bool, *bool) (*biz.Organization, error)); ok { - return returnFunc(ctx, id, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, restrictContractCreationToOrgAdmins) + if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID, *bool, []string, *bool, *bool, *int) (*biz.Organization, error)); ok { + return returnFunc(ctx, id, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, restrictContractCreationToOrgAdmins, apiTokenInactivityThresholdDays) } - if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID, *bool, []string, *bool, *bool) *biz.Organization); ok { - r0 = returnFunc(ctx, id, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, restrictContractCreationToOrgAdmins) + if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID, *bool, []string, *bool, *bool, *int) *biz.Organization); ok { + r0 = returnFunc(ctx, id, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, restrictContractCreationToOrgAdmins, apiTokenInactivityThresholdDays) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*biz.Organization) } } - if returnFunc, ok := ret.Get(1).(func(context.Context, uuid.UUID, *bool, []string, *bool, *bool) error); ok { - r1 = returnFunc(ctx, id, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, restrictContractCreationToOrgAdmins) + if returnFunc, ok := ret.Get(1).(func(context.Context, uuid.UUID, *bool, []string, *bool, *bool, *int) error); ok { + r1 = returnFunc(ctx, id, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, restrictContractCreationToOrgAdmins, apiTokenInactivityThresholdDays) } else { r1 = ret.Error(1) } @@ -340,11 +402,12 @@ type OrganizationRepo_Update_Call struct { // - policiesAllowedHostnames []string // - preventImplicitWorkflowCreation *bool // - restrictContractCreationToOrgAdmins *bool -func (_e *OrganizationRepo_Expecter) Update(ctx interface{}, id interface{}, blockOnPolicyViolation interface{}, policiesAllowedHostnames interface{}, preventImplicitWorkflowCreation interface{}, restrictContractCreationToOrgAdmins interface{}) *OrganizationRepo_Update_Call { - return &OrganizationRepo_Update_Call{Call: _e.mock.On("Update", ctx, id, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, restrictContractCreationToOrgAdmins)} +// - apiTokenInactivityThresholdDays *int +func (_e *OrganizationRepo_Expecter) Update(ctx interface{}, id interface{}, blockOnPolicyViolation interface{}, policiesAllowedHostnames interface{}, preventImplicitWorkflowCreation interface{}, restrictContractCreationToOrgAdmins interface{}, apiTokenInactivityThresholdDays interface{}) *OrganizationRepo_Update_Call { + return &OrganizationRepo_Update_Call{Call: _e.mock.On("Update", ctx, id, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, restrictContractCreationToOrgAdmins, apiTokenInactivityThresholdDays)} } -func (_c *OrganizationRepo_Update_Call) Run(run func(ctx context.Context, id uuid.UUID, blockOnPolicyViolation *bool, policiesAllowedHostnames []string, preventImplicitWorkflowCreation *bool, restrictContractCreationToOrgAdmins *bool)) *OrganizationRepo_Update_Call { +func (_c *OrganizationRepo_Update_Call) Run(run func(ctx context.Context, id uuid.UUID, blockOnPolicyViolation *bool, policiesAllowedHostnames []string, preventImplicitWorkflowCreation *bool, restrictContractCreationToOrgAdmins *bool, apiTokenInactivityThresholdDays *int)) *OrganizationRepo_Update_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -370,6 +433,10 @@ func (_c *OrganizationRepo_Update_Call) Run(run func(ctx context.Context, id uui if args[5] != nil { arg5 = args[5].(*bool) } + var arg6 *int + if args[6] != nil { + arg6 = args[6].(*int) + } run( arg0, arg1, @@ -377,6 +444,7 @@ func (_c *OrganizationRepo_Update_Call) Run(run func(ctx context.Context, id uui arg3, arg4, arg5, + arg6, ) }) return _c @@ -387,7 +455,7 @@ func (_c *OrganizationRepo_Update_Call) Return(organization *biz.Organization, e return _c } -func (_c *OrganizationRepo_Update_Call) RunAndReturn(run func(ctx context.Context, id uuid.UUID, blockOnPolicyViolation *bool, policiesAllowedHostnames []string, preventImplicitWorkflowCreation *bool, restrictContractCreationToOrgAdmins *bool) (*biz.Organization, error)) *OrganizationRepo_Update_Call { +func (_c *OrganizationRepo_Update_Call) RunAndReturn(run func(ctx context.Context, id uuid.UUID, blockOnPolicyViolation *bool, policiesAllowedHostnames []string, preventImplicitWorkflowCreation *bool, restrictContractCreationToOrgAdmins *bool, apiTokenInactivityThresholdDays *int) (*biz.Organization, error)) *OrganizationRepo_Update_Call { _c.Call.Return(run) return _c } diff --git a/app/controlplane/pkg/biz/organization.go b/app/controlplane/pkg/biz/organization.go index 8d5a98c55..cc673a74a 100644 --- a/app/controlplane/pkg/biz/organization.go +++ b/app/controlplane/pkg/biz/organization.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -43,14 +43,19 @@ type Organization struct { PreventImplicitWorkflowCreation bool // RestrictContractCreationToOrgAdmins restricts contract creation (org-level and project-level) to only organization admins RestrictContractCreationToOrgAdmins bool + // APITokenInactivityThresholdDays is the number of days after which inactive API tokens are auto-revoked. + // nil means the feature is disabled. + APITokenInactivityThresholdDays *int } type OrganizationRepo interface { FindByID(ctx context.Context, orgID uuid.UUID) (*Organization, error) FindByName(ctx context.Context, name string) (*Organization, error) Create(ctx context.Context, name string) (*Organization, error) - Update(ctx context.Context, id uuid.UUID, blockOnPolicyViolation *bool, policiesAllowedHostnames []string, preventImplicitWorkflowCreation *bool, restrictContractCreationToOrgAdmins *bool) (*Organization, error) + Update(ctx context.Context, id uuid.UUID, blockOnPolicyViolation *bool, policiesAllowedHostnames []string, preventImplicitWorkflowCreation *bool, restrictContractCreationToOrgAdmins *bool, apiTokenInactivityThresholdDays *int) (*Organization, error) Delete(ctx context.Context, ID uuid.UUID) error + // FindWithTokenInactivityThreshold returns orgs that have api_token_inactivity_threshold_days set (non-nil). + FindWithTokenInactivityThreshold(ctx context.Context) ([]*Organization, error) } type OrganizationUseCase struct { @@ -189,7 +194,7 @@ func (uc *OrganizationUseCase) doCreate(ctx context.Context, name string, opts . return org, nil } -func (uc *OrganizationUseCase) Update(ctx context.Context, userID, orgName string, blockOnPolicyViolation *bool, policiesAllowedHostnames []string, preventImplicitWorkflowCreation *bool, restrictContractCreationToOrgAdmins *bool) (*Organization, error) { +func (uc *OrganizationUseCase) Update(ctx context.Context, userID, orgName string, blockOnPolicyViolation *bool, policiesAllowedHostnames []string, preventImplicitWorkflowCreation *bool, restrictContractCreationToOrgAdmins *bool, apiTokenInactivityThresholdDays *int) (*Organization, error) { userUUID, err := uuid.Parse(userID) if err != nil { return nil, NewErrInvalidUUID(err) @@ -209,7 +214,7 @@ func (uc *OrganizationUseCase) Update(ctx context.Context, userID, orgName strin } // Perform the update - org, err := uc.orgRepo.Update(ctx, orgUUID, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, restrictContractCreationToOrgAdmins) + org, err := uc.orgRepo.Update(ctx, orgUUID, blockOnPolicyViolation, policiesAllowedHostnames, preventImplicitWorkflowCreation, restrictContractCreationToOrgAdmins, apiTokenInactivityThresholdDays) if err != nil { return nil, fmt.Errorf("failed to update organization: %w", err) } else if org == nil { diff --git a/app/controlplane/pkg/biz/organization_integration_test.go b/app/controlplane/pkg/biz/organization_integration_test.go index 801a86fb2..2f3ae0659 100644 --- a/app/controlplane/pkg/biz/organization_integration_test.go +++ b/app/controlplane/pkg/biz/organization_integration_test.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -118,7 +118,7 @@ func (s *OrgIntegrationTestSuite) TestUpdate() { s.Run("org non existent", func() { // org not found - _, err := s.Organization.Update(ctx, s.user.ID, uuid.NewString(), nil, nil, nil, nil) + _, err := s.Organization.Update(ctx, s.user.ID, uuid.NewString(), nil, nil, nil, nil, nil) s.Error(err) s.True(biz.IsNotFound(err)) }) @@ -126,35 +126,35 @@ func (s *OrgIntegrationTestSuite) TestUpdate() { s.Run("org not accessible to user", func() { org2, err := s.Organization.CreateWithRandomName(ctx) require.NoError(s.T(), err) - _, err = s.Organization.Update(ctx, s.user.ID, org2.Name, nil, nil, nil, nil) + _, err = s.Organization.Update(ctx, s.user.ID, org2.Name, nil, nil, nil, nil, nil) s.Error(err) s.True(biz.IsNotFound(err)) }) s.Run("valid block on policy violation update", func() { - got, err := s.Organization.Update(ctx, s.user.ID, s.org.Name, toPtrBool(true), nil, nil, nil) + got, err := s.Organization.Update(ctx, s.user.ID, s.org.Name, toPtrBool(true), nil, nil, nil, nil) s.NoError(err) s.True(got.BlockOnPolicyViolation) }) s.Run("valid policy allowed hostnames update", func() { - got, err := s.Organization.Update(ctx, s.user.ID, s.org.Name, nil, []string{"foo.com", "bar.com"}, nil, nil) + got, err := s.Organization.Update(ctx, s.user.ID, s.org.Name, nil, []string{"foo.com", "bar.com"}, nil, nil, nil) s.NoError(err) s.Equal([]string{"foo.com", "bar.com"}, got.PoliciesAllowedHostnames) }) s.Run("clear policy allowed hostnames", func() { - got, err := s.Organization.Update(ctx, s.user.ID, s.org.Name, nil, []string{}, nil, nil) + got, err := s.Organization.Update(ctx, s.user.ID, s.org.Name, nil, []string{}, nil, nil, nil) s.NoError(err) s.Equal([]string{}, got.PoliciesAllowedHostnames) }) s.Run("but not passing a value doesn't clear the hostnames value", func() { - got, err := s.Organization.Update(ctx, s.user.ID, s.org.Name, nil, []string{"foo.com", "bar.com"}, nil, nil) + got, err := s.Organization.Update(ctx, s.user.ID, s.org.Name, nil, []string{"foo.com", "bar.com"}, nil, nil, nil) s.NoError(err) s.Equal([]string{"foo.com", "bar.com"}, got.PoliciesAllowedHostnames) - got, err = s.Organization.Update(ctx, s.user.ID, s.org.Name, nil, nil, nil, nil) + got, err = s.Organization.Update(ctx, s.user.ID, s.org.Name, nil, nil, nil, nil, nil) s.NoError(err) s.Equal([]string{"foo.com", "bar.com"}, got.PoliciesAllowedHostnames) }) diff --git a/app/controlplane/pkg/biz/testhelpers/database.go b/app/controlplane/pkg/biz/testhelpers/database.go index 26324354f..d2fab4382 100644 --- a/app/controlplane/pkg/biz/testhelpers/database.go +++ b/app/controlplane/pkg/biz/testhelpers/database.go @@ -90,6 +90,7 @@ type TestingRepos struct { OrganizationRepo biz.OrganizationRepo GroupRepo biz.GroupRepo OrgInvitationRepo biz.OrgInvitationRepo + APITokenRepo biz.APITokenRepo } type newTestingOpts struct { diff --git a/app/controlplane/pkg/biz/testhelpers/wire_gen.go b/app/controlplane/pkg/biz/testhelpers/wire_gen.go index b6f839fcf..29666ff42 100644 --- a/app/controlplane/pkg/biz/testhelpers/wire_gen.go +++ b/app/controlplane/pkg/biz/testhelpers/wire_gen.go @@ -170,6 +170,7 @@ func WireTestData(testDatabase *TestDatabase, t *testing.T, logger log.Logger, r OrganizationRepo: organizationRepo, GroupRepo: groupRepo, OrgInvitationRepo: orgInvitationRepo, + APITokenRepo: apiTokenRepo, } testingUseCases := &TestingUseCases{ DB: testDatabase, diff --git a/app/controlplane/pkg/biz/workflow_integration_test.go b/app/controlplane/pkg/biz/workflow_integration_test.go index 5bc303f00..1e8937c3e 100644 --- a/app/controlplane/pkg/biz/workflow_integration_test.go +++ b/app/controlplane/pkg/biz/workflow_integration_test.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -187,7 +187,7 @@ func (s *workflowIntegrationTestSuite) TestCreate() { // Enable implicit workflow creation prevention for testing orgID, _ := uuid.Parse(s.org.ID) - _, err = s.Repos.OrganizationRepo.Update(ctx, orgID, nil, nil, toPtrBool(true), nil) + _, err = s.Repos.OrganizationRepo.Update(ctx, orgID, nil, nil, toPtrBool(true), nil, nil) s.Require().NoError(err) for _, tc := range testCases { diff --git a/app/controlplane/pkg/data/apitoken.go b/app/controlplane/pkg/data/apitoken.go index 2f9a97699..257616565 100644 --- a/app/controlplane/pkg/data/apitoken.go +++ b/app/controlplane/pkg/data/apitoken.go @@ -1,5 +1,5 @@ // -// Copyright 2023-2025 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -163,6 +163,34 @@ func (r *APITokenRepo) Revoke(ctx context.Context, orgID *uuid.UUID, id uuid.UUI return nil } +// FindInactive returns tokens that have been inactive since the given cutoff. +func (r *APITokenRepo) FindInactive(ctx context.Context, orgID uuid.UUID, inactiveSince time.Time) ([]*biz.APIToken, error) { + // A token is inactive if last_used_at < inactiveSince (when used before), + // or created_at < inactiveSince (when never used). + tokens, err := r.data.DB.APIToken.Query(). + Where( + apitoken.OrganizationIDEQ(orgID), + apitoken.RevokedAtIsNil(), + apitoken.Or( + apitoken.And(apitoken.LastUsedAtNotNil(), apitoken.LastUsedAtLT(inactiveSince)), + apitoken.And(apitoken.LastUsedAtIsNil(), apitoken.CreatedAtLT(inactiveSince)), + ), + ). + WithOrganization(). + WithProject(). + All(ctx) + if err != nil { + return nil, fmt.Errorf("querying inactive tokens: %w", err) + } + + result := make([]*biz.APIToken, 0, len(tokens)) + for _, t := range tokens { + result = append(result, entAPITokenToBiz(t)) + } + + return result, nil +} + func (r *APITokenRepo) UpdateExpiration(ctx context.Context, id uuid.UUID, expiresAt time.Time) error { err := r.data.DB.APIToken.UpdateOneID(id).SetExpiresAt(expiresAt).Exec(ctx) if err != nil { diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/20260211225609.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20260211225609.sql new file mode 100644 index 000000000..dcc18ca45 --- /dev/null +++ b/app/controlplane/pkg/data/ent/migrate/migrations/20260211225609.sql @@ -0,0 +1,2 @@ +-- Modify "organizations" table +ALTER TABLE "organizations" ADD COLUMN "api_token_inactivity_threshold_days" bigint NULL; diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum index 04f33f296..e5390dda9 100644 --- a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum +++ b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:HfuhzadCjBbwi+tW82Z8bEYHpv2onjcYBfwov7Z/9II= +h1:zokBUIf1GE1Ih+WH8XycjROBmH2uLn+Kh6sw9vVhTl4= 20230706165452_init-schema.sql h1:VvqbNFEQnCvUVyj2iDYVQQxDM0+sSXqocpt/5H64k8M= 20230710111950-cas-backend.sql h1:A8iBuSzZIEbdsv9ipBtscZQuaBp3V5/VMw7eZH6GX+g= 20230712094107-cas-backends-workflow-runs.sql h1:a5rzxpVGyd56nLRSsKrmCFc9sebg65RWzLghKHh5xvI= @@ -124,3 +124,4 @@ h1:HfuhzadCjBbwi+tW82Z8bEYHpv2onjcYBfwov7Z/9II= 20251217164302.sql h1:OL3OCqWsMtv06RfIlQNcdLMbt4Tz91Lijpbkxqwt7zM= 20260112115927.sql h1:/RKhzT5dRphgeBitxBfo3a3fqLVgvmVZxxqe9fH8lkg= 20260204113827.sql h1:rlJNf8QRfqOfDHf2GUi+59Rgv2BkSbMTPuMalPsMkZg= +20260211225609.sql h1:DTkyg3oZSV99uPGl+vOuK9FSlEumXwoYCgchUhsg/P4= diff --git a/app/controlplane/pkg/data/ent/migrate/schema.go b/app/controlplane/pkg/data/ent/migrate/schema.go index bd6167e14..849c7ac70 100644 --- a/app/controlplane/pkg/data/ent/migrate/schema.go +++ b/app/controlplane/pkg/data/ent/migrate/schema.go @@ -432,6 +432,7 @@ var ( {Name: "policies_allowed_hostnames", Type: field.TypeJSON, Nullable: true}, {Name: "prevent_implicit_workflow_creation", Type: field.TypeBool, Default: false}, {Name: "restrict_contract_creation_to_org_admins", Type: field.TypeBool, Default: false}, + {Name: "api_token_inactivity_threshold_days", Type: field.TypeInt, Nullable: true}, } // OrganizationsTable holds the schema information for the "organizations" table. OrganizationsTable = &schema.Table{ diff --git a/app/controlplane/pkg/data/ent/mutation.go b/app/controlplane/pkg/data/ent/mutation.go index aa2ed5599..ffff2a12c 100644 --- a/app/controlplane/pkg/data/ent/mutation.go +++ b/app/controlplane/pkg/data/ent/mutation.go @@ -8746,6 +8746,8 @@ type OrganizationMutation struct { appendpolicies_allowed_hostnames []string prevent_implicit_workflow_creation *bool restrict_contract_creation_to_org_admins *bool + api_token_inactivity_threshold_days *int + addapi_token_inactivity_threshold_days *int clearedFields map[string]struct{} memberships map[uuid.UUID]struct{} removedmemberships map[uuid.UUID]struct{} @@ -9210,6 +9212,76 @@ func (m *OrganizationMutation) ResetRestrictContractCreationToOrgAdmins() { m.restrict_contract_creation_to_org_admins = nil } +// SetAPITokenInactivityThresholdDays sets the "api_token_inactivity_threshold_days" field. +func (m *OrganizationMutation) SetAPITokenInactivityThresholdDays(i int) { + m.api_token_inactivity_threshold_days = &i + m.addapi_token_inactivity_threshold_days = nil +} + +// APITokenInactivityThresholdDays returns the value of the "api_token_inactivity_threshold_days" field in the mutation. +func (m *OrganizationMutation) APITokenInactivityThresholdDays() (r int, exists bool) { + v := m.api_token_inactivity_threshold_days + if v == nil { + return + } + return *v, true +} + +// OldAPITokenInactivityThresholdDays returns the old "api_token_inactivity_threshold_days" field's value of the Organization entity. +// If the Organization object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *OrganizationMutation) OldAPITokenInactivityThresholdDays(ctx context.Context) (v *int, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAPITokenInactivityThresholdDays is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAPITokenInactivityThresholdDays requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAPITokenInactivityThresholdDays: %w", err) + } + return oldValue.APITokenInactivityThresholdDays, nil +} + +// AddAPITokenInactivityThresholdDays adds i to the "api_token_inactivity_threshold_days" field. +func (m *OrganizationMutation) AddAPITokenInactivityThresholdDays(i int) { + if m.addapi_token_inactivity_threshold_days != nil { + *m.addapi_token_inactivity_threshold_days += i + } else { + m.addapi_token_inactivity_threshold_days = &i + } +} + +// AddedAPITokenInactivityThresholdDays returns the value that was added to the "api_token_inactivity_threshold_days" field in this mutation. +func (m *OrganizationMutation) AddedAPITokenInactivityThresholdDays() (r int, exists bool) { + v := m.addapi_token_inactivity_threshold_days + if v == nil { + return + } + return *v, true +} + +// ClearAPITokenInactivityThresholdDays clears the value of the "api_token_inactivity_threshold_days" field. +func (m *OrganizationMutation) ClearAPITokenInactivityThresholdDays() { + m.api_token_inactivity_threshold_days = nil + m.addapi_token_inactivity_threshold_days = nil + m.clearedFields[organization.FieldAPITokenInactivityThresholdDays] = struct{}{} +} + +// APITokenInactivityThresholdDaysCleared returns if the "api_token_inactivity_threshold_days" field was cleared in this mutation. +func (m *OrganizationMutation) APITokenInactivityThresholdDaysCleared() bool { + _, ok := m.clearedFields[organization.FieldAPITokenInactivityThresholdDays] + return ok +} + +// ResetAPITokenInactivityThresholdDays resets all changes to the "api_token_inactivity_threshold_days" field. +func (m *OrganizationMutation) ResetAPITokenInactivityThresholdDays() { + m.api_token_inactivity_threshold_days = nil + m.addapi_token_inactivity_threshold_days = nil + delete(m.clearedFields, organization.FieldAPITokenInactivityThresholdDays) +} + // AddMembershipIDs adds the "memberships" edge to the Membership entity by ids. func (m *OrganizationMutation) AddMembershipIDs(ids ...uuid.UUID) { if m.memberships == nil { @@ -9676,7 +9748,7 @@ func (m *OrganizationMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *OrganizationMutation) Fields() []string { - fields := make([]string, 0, 8) + fields := make([]string, 0, 9) if m.name != nil { fields = append(fields, organization.FieldName) } @@ -9701,6 +9773,9 @@ func (m *OrganizationMutation) Fields() []string { if m.restrict_contract_creation_to_org_admins != nil { fields = append(fields, organization.FieldRestrictContractCreationToOrgAdmins) } + if m.api_token_inactivity_threshold_days != nil { + fields = append(fields, organization.FieldAPITokenInactivityThresholdDays) + } return fields } @@ -9725,6 +9800,8 @@ func (m *OrganizationMutation) Field(name string) (ent.Value, bool) { return m.PreventImplicitWorkflowCreation() case organization.FieldRestrictContractCreationToOrgAdmins: return m.RestrictContractCreationToOrgAdmins() + case organization.FieldAPITokenInactivityThresholdDays: + return m.APITokenInactivityThresholdDays() } return nil, false } @@ -9750,6 +9827,8 @@ func (m *OrganizationMutation) OldField(ctx context.Context, name string) (ent.V return m.OldPreventImplicitWorkflowCreation(ctx) case organization.FieldRestrictContractCreationToOrgAdmins: return m.OldRestrictContractCreationToOrgAdmins(ctx) + case organization.FieldAPITokenInactivityThresholdDays: + return m.OldAPITokenInactivityThresholdDays(ctx) } return nil, fmt.Errorf("unknown Organization field %s", name) } @@ -9815,6 +9894,13 @@ func (m *OrganizationMutation) SetField(name string, value ent.Value) error { } m.SetRestrictContractCreationToOrgAdmins(v) return nil + case organization.FieldAPITokenInactivityThresholdDays: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAPITokenInactivityThresholdDays(v) + return nil } return fmt.Errorf("unknown Organization field %s", name) } @@ -9822,13 +9908,21 @@ func (m *OrganizationMutation) SetField(name string, value ent.Value) error { // AddedFields returns all numeric fields that were incremented/decremented during // this mutation. func (m *OrganizationMutation) AddedFields() []string { - return nil + var fields []string + if m.addapi_token_inactivity_threshold_days != nil { + fields = append(fields, organization.FieldAPITokenInactivityThresholdDays) + } + return fields } // AddedField returns the numeric value that was incremented/decremented on a field // with the given name. The second boolean return value indicates that this field // was not set, or was not defined in the schema. func (m *OrganizationMutation) AddedField(name string) (ent.Value, bool) { + switch name { + case organization.FieldAPITokenInactivityThresholdDays: + return m.AddedAPITokenInactivityThresholdDays() + } return nil, false } @@ -9837,6 +9931,13 @@ func (m *OrganizationMutation) AddedField(name string) (ent.Value, bool) { // type. func (m *OrganizationMutation) AddField(name string, value ent.Value) error { switch name { + case organization.FieldAPITokenInactivityThresholdDays: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddAPITokenInactivityThresholdDays(v) + return nil } return fmt.Errorf("unknown Organization numeric field %s", name) } @@ -9851,6 +9952,9 @@ func (m *OrganizationMutation) ClearedFields() []string { if m.FieldCleared(organization.FieldPoliciesAllowedHostnames) { fields = append(fields, organization.FieldPoliciesAllowedHostnames) } + if m.FieldCleared(organization.FieldAPITokenInactivityThresholdDays) { + fields = append(fields, organization.FieldAPITokenInactivityThresholdDays) + } return fields } @@ -9871,6 +9975,9 @@ func (m *OrganizationMutation) ClearField(name string) error { case organization.FieldPoliciesAllowedHostnames: m.ClearPoliciesAllowedHostnames() return nil + case organization.FieldAPITokenInactivityThresholdDays: + m.ClearAPITokenInactivityThresholdDays() + return nil } return fmt.Errorf("unknown Organization nullable field %s", name) } @@ -9903,6 +10010,9 @@ func (m *OrganizationMutation) ResetField(name string) error { case organization.FieldRestrictContractCreationToOrgAdmins: m.ResetRestrictContractCreationToOrgAdmins() return nil + case organization.FieldAPITokenInactivityThresholdDays: + m.ResetAPITokenInactivityThresholdDays() + return nil } return fmt.Errorf("unknown Organization field %s", name) } diff --git a/app/controlplane/pkg/data/ent/organization.go b/app/controlplane/pkg/data/ent/organization.go index c9d7d2715..908059c3f 100644 --- a/app/controlplane/pkg/data/ent/organization.go +++ b/app/controlplane/pkg/data/ent/organization.go @@ -35,6 +35,8 @@ type Organization struct { PreventImplicitWorkflowCreation bool `json:"prevent_implicit_workflow_creation,omitempty"` // RestrictContractCreationToOrgAdmins holds the value of the "restrict_contract_creation_to_org_admins" field. RestrictContractCreationToOrgAdmins bool `json:"restrict_contract_creation_to_org_admins,omitempty"` + // APITokenInactivityThresholdDays holds the value of the "api_token_inactivity_threshold_days" field. + APITokenInactivityThresholdDays *int `json:"api_token_inactivity_threshold_days,omitempty"` // Edges holds the relations/edges for other nodes in the graph. // The values are being populated by the OrganizationQuery when eager-loading is set. Edges OrganizationEdges `json:"edges"` @@ -145,6 +147,8 @@ func (*Organization) scanValues(columns []string) ([]any, error) { values[i] = new([]byte) case organization.FieldBlockOnPolicyViolation, organization.FieldPreventImplicitWorkflowCreation, organization.FieldRestrictContractCreationToOrgAdmins: values[i] = new(sql.NullBool) + case organization.FieldAPITokenInactivityThresholdDays: + values[i] = new(sql.NullInt64) case organization.FieldName: values[i] = new(sql.NullString) case organization.FieldCreatedAt, organization.FieldUpdatedAt, organization.FieldDeletedAt: @@ -222,6 +226,13 @@ func (_m *Organization) assignValues(columns []string, values []any) error { } else if value.Valid { _m.RestrictContractCreationToOrgAdmins = value.Bool } + case organization.FieldAPITokenInactivityThresholdDays: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field api_token_inactivity_threshold_days", values[i]) + } else if value.Valid { + _m.APITokenInactivityThresholdDays = new(int) + *_m.APITokenInactivityThresholdDays = int(value.Int64) + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -321,6 +332,11 @@ func (_m *Organization) String() string { builder.WriteString(", ") builder.WriteString("restrict_contract_creation_to_org_admins=") builder.WriteString(fmt.Sprintf("%v", _m.RestrictContractCreationToOrgAdmins)) + builder.WriteString(", ") + if v := _m.APITokenInactivityThresholdDays; v != nil { + builder.WriteString("api_token_inactivity_threshold_days=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } builder.WriteByte(')') return builder.String() } diff --git a/app/controlplane/pkg/data/ent/organization/organization.go b/app/controlplane/pkg/data/ent/organization/organization.go index 603667a7a..07407aec3 100644 --- a/app/controlplane/pkg/data/ent/organization/organization.go +++ b/app/controlplane/pkg/data/ent/organization/organization.go @@ -31,6 +31,8 @@ const ( FieldPreventImplicitWorkflowCreation = "prevent_implicit_workflow_creation" // FieldRestrictContractCreationToOrgAdmins holds the string denoting the restrict_contract_creation_to_org_admins field in the database. FieldRestrictContractCreationToOrgAdmins = "restrict_contract_creation_to_org_admins" + // FieldAPITokenInactivityThresholdDays holds the string denoting the api_token_inactivity_threshold_days field in the database. + FieldAPITokenInactivityThresholdDays = "api_token_inactivity_threshold_days" // EdgeMemberships holds the string denoting the memberships edge name in mutations. EdgeMemberships = "memberships" // EdgeWorkflowContracts holds the string denoting the workflow_contracts edge name in mutations. @@ -118,6 +120,7 @@ var Columns = []string{ FieldPoliciesAllowedHostnames, FieldPreventImplicitWorkflowCreation, FieldRestrictContractCreationToOrgAdmins, + FieldAPITokenInactivityThresholdDays, } // ValidColumn reports if the column name is valid (part of the table columns). @@ -188,6 +191,11 @@ func ByRestrictContractCreationToOrgAdmins(opts ...sql.OrderTermOption) OrderOpt return sql.OrderByField(FieldRestrictContractCreationToOrgAdmins, opts...).ToFunc() } +// ByAPITokenInactivityThresholdDays orders the results by the api_token_inactivity_threshold_days field. +func ByAPITokenInactivityThresholdDays(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAPITokenInactivityThresholdDays, opts...).ToFunc() +} + // ByMembershipsCount orders the results by memberships count. func ByMembershipsCount(opts ...sql.OrderTermOption) OrderOption { return func(s *sql.Selector) { diff --git a/app/controlplane/pkg/data/ent/organization/where.go b/app/controlplane/pkg/data/ent/organization/where.go index 022a28f8e..f2823a5d4 100644 --- a/app/controlplane/pkg/data/ent/organization/where.go +++ b/app/controlplane/pkg/data/ent/organization/where.go @@ -91,6 +91,11 @@ func RestrictContractCreationToOrgAdmins(v bool) predicate.Organization { return predicate.Organization(sql.FieldEQ(FieldRestrictContractCreationToOrgAdmins, v)) } +// APITokenInactivityThresholdDays applies equality check predicate on the "api_token_inactivity_threshold_days" field. It's identical to APITokenInactivityThresholdDaysEQ. +func APITokenInactivityThresholdDays(v int) predicate.Organization { + return predicate.Organization(sql.FieldEQ(FieldAPITokenInactivityThresholdDays, v)) +} + // NameEQ applies the EQ predicate on the "name" field. func NameEQ(v string) predicate.Organization { return predicate.Organization(sql.FieldEQ(FieldName, v)) @@ -326,6 +331,56 @@ func RestrictContractCreationToOrgAdminsNEQ(v bool) predicate.Organization { return predicate.Organization(sql.FieldNEQ(FieldRestrictContractCreationToOrgAdmins, v)) } +// APITokenInactivityThresholdDaysEQ applies the EQ predicate on the "api_token_inactivity_threshold_days" field. +func APITokenInactivityThresholdDaysEQ(v int) predicate.Organization { + return predicate.Organization(sql.FieldEQ(FieldAPITokenInactivityThresholdDays, v)) +} + +// APITokenInactivityThresholdDaysNEQ applies the NEQ predicate on the "api_token_inactivity_threshold_days" field. +func APITokenInactivityThresholdDaysNEQ(v int) predicate.Organization { + return predicate.Organization(sql.FieldNEQ(FieldAPITokenInactivityThresholdDays, v)) +} + +// APITokenInactivityThresholdDaysIn applies the In predicate on the "api_token_inactivity_threshold_days" field. +func APITokenInactivityThresholdDaysIn(vs ...int) predicate.Organization { + return predicate.Organization(sql.FieldIn(FieldAPITokenInactivityThresholdDays, vs...)) +} + +// APITokenInactivityThresholdDaysNotIn applies the NotIn predicate on the "api_token_inactivity_threshold_days" field. +func APITokenInactivityThresholdDaysNotIn(vs ...int) predicate.Organization { + return predicate.Organization(sql.FieldNotIn(FieldAPITokenInactivityThresholdDays, vs...)) +} + +// APITokenInactivityThresholdDaysGT applies the GT predicate on the "api_token_inactivity_threshold_days" field. +func APITokenInactivityThresholdDaysGT(v int) predicate.Organization { + return predicate.Organization(sql.FieldGT(FieldAPITokenInactivityThresholdDays, v)) +} + +// APITokenInactivityThresholdDaysGTE applies the GTE predicate on the "api_token_inactivity_threshold_days" field. +func APITokenInactivityThresholdDaysGTE(v int) predicate.Organization { + return predicate.Organization(sql.FieldGTE(FieldAPITokenInactivityThresholdDays, v)) +} + +// APITokenInactivityThresholdDaysLT applies the LT predicate on the "api_token_inactivity_threshold_days" field. +func APITokenInactivityThresholdDaysLT(v int) predicate.Organization { + return predicate.Organization(sql.FieldLT(FieldAPITokenInactivityThresholdDays, v)) +} + +// APITokenInactivityThresholdDaysLTE applies the LTE predicate on the "api_token_inactivity_threshold_days" field. +func APITokenInactivityThresholdDaysLTE(v int) predicate.Organization { + return predicate.Organization(sql.FieldLTE(FieldAPITokenInactivityThresholdDays, v)) +} + +// APITokenInactivityThresholdDaysIsNil applies the IsNil predicate on the "api_token_inactivity_threshold_days" field. +func APITokenInactivityThresholdDaysIsNil() predicate.Organization { + return predicate.Organization(sql.FieldIsNull(FieldAPITokenInactivityThresholdDays)) +} + +// APITokenInactivityThresholdDaysNotNil applies the NotNil predicate on the "api_token_inactivity_threshold_days" field. +func APITokenInactivityThresholdDaysNotNil() predicate.Organization { + return predicate.Organization(sql.FieldNotNull(FieldAPITokenInactivityThresholdDays)) +} + // HasMemberships applies the HasEdge predicate on the "memberships" edge. func HasMemberships() predicate.Organization { return predicate.Organization(func(s *sql.Selector) { diff --git a/app/controlplane/pkg/data/ent/organization_create.go b/app/controlplane/pkg/data/ent/organization_create.go index 166afdcf4..3552b9791 100644 --- a/app/controlplane/pkg/data/ent/organization_create.go +++ b/app/controlplane/pkg/data/ent/organization_create.go @@ -128,6 +128,20 @@ func (_c *OrganizationCreate) SetNillableRestrictContractCreationToOrgAdmins(v * return _c } +// SetAPITokenInactivityThresholdDays sets the "api_token_inactivity_threshold_days" field. +func (_c *OrganizationCreate) SetAPITokenInactivityThresholdDays(v int) *OrganizationCreate { + _c.mutation.SetAPITokenInactivityThresholdDays(v) + return _c +} + +// SetNillableAPITokenInactivityThresholdDays sets the "api_token_inactivity_threshold_days" field if the given value is not nil. +func (_c *OrganizationCreate) SetNillableAPITokenInactivityThresholdDays(v *int) *OrganizationCreate { + if v != nil { + _c.SetAPITokenInactivityThresholdDays(*v) + } + return _c +} + // SetID sets the "id" field. func (_c *OrganizationCreate) SetID(v uuid.UUID) *OrganizationCreate { _c.mutation.SetID(v) @@ -411,6 +425,10 @@ func (_c *OrganizationCreate) createSpec() (*Organization, *sqlgraph.CreateSpec) _spec.SetField(organization.FieldRestrictContractCreationToOrgAdmins, field.TypeBool, value) _node.RestrictContractCreationToOrgAdmins = value } + if value, ok := _c.mutation.APITokenInactivityThresholdDays(); ok { + _spec.SetField(organization.FieldAPITokenInactivityThresholdDays, field.TypeInt, value) + _node.APITokenInactivityThresholdDays = &value + } if nodes := _c.mutation.MembershipsIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -687,6 +705,30 @@ func (u *OrganizationUpsert) UpdateRestrictContractCreationToOrgAdmins() *Organi return u } +// SetAPITokenInactivityThresholdDays sets the "api_token_inactivity_threshold_days" field. +func (u *OrganizationUpsert) SetAPITokenInactivityThresholdDays(v int) *OrganizationUpsert { + u.Set(organization.FieldAPITokenInactivityThresholdDays, v) + return u +} + +// UpdateAPITokenInactivityThresholdDays sets the "api_token_inactivity_threshold_days" field to the value that was provided on create. +func (u *OrganizationUpsert) UpdateAPITokenInactivityThresholdDays() *OrganizationUpsert { + u.SetExcluded(organization.FieldAPITokenInactivityThresholdDays) + return u +} + +// AddAPITokenInactivityThresholdDays adds v to the "api_token_inactivity_threshold_days" field. +func (u *OrganizationUpsert) AddAPITokenInactivityThresholdDays(v int) *OrganizationUpsert { + u.Add(organization.FieldAPITokenInactivityThresholdDays, v) + return u +} + +// ClearAPITokenInactivityThresholdDays clears the value of the "api_token_inactivity_threshold_days" field. +func (u *OrganizationUpsert) ClearAPITokenInactivityThresholdDays() *OrganizationUpsert { + u.SetNull(organization.FieldAPITokenInactivityThresholdDays) + return u +} + // UpdateNewValues updates the mutable fields using the new values that were set on create except the ID field. // Using this option is equivalent to using: // @@ -850,6 +892,34 @@ func (u *OrganizationUpsertOne) UpdateRestrictContractCreationToOrgAdmins() *Org }) } +// SetAPITokenInactivityThresholdDays sets the "api_token_inactivity_threshold_days" field. +func (u *OrganizationUpsertOne) SetAPITokenInactivityThresholdDays(v int) *OrganizationUpsertOne { + return u.Update(func(s *OrganizationUpsert) { + s.SetAPITokenInactivityThresholdDays(v) + }) +} + +// AddAPITokenInactivityThresholdDays adds v to the "api_token_inactivity_threshold_days" field. +func (u *OrganizationUpsertOne) AddAPITokenInactivityThresholdDays(v int) *OrganizationUpsertOne { + return u.Update(func(s *OrganizationUpsert) { + s.AddAPITokenInactivityThresholdDays(v) + }) +} + +// UpdateAPITokenInactivityThresholdDays sets the "api_token_inactivity_threshold_days" field to the value that was provided on create. +func (u *OrganizationUpsertOne) UpdateAPITokenInactivityThresholdDays() *OrganizationUpsertOne { + return u.Update(func(s *OrganizationUpsert) { + s.UpdateAPITokenInactivityThresholdDays() + }) +} + +// ClearAPITokenInactivityThresholdDays clears the value of the "api_token_inactivity_threshold_days" field. +func (u *OrganizationUpsertOne) ClearAPITokenInactivityThresholdDays() *OrganizationUpsertOne { + return u.Update(func(s *OrganizationUpsert) { + s.ClearAPITokenInactivityThresholdDays() + }) +} + // Exec executes the query. func (u *OrganizationUpsertOne) Exec(ctx context.Context) error { if len(u.create.conflict) == 0 { @@ -1180,6 +1250,34 @@ func (u *OrganizationUpsertBulk) UpdateRestrictContractCreationToOrgAdmins() *Or }) } +// SetAPITokenInactivityThresholdDays sets the "api_token_inactivity_threshold_days" field. +func (u *OrganizationUpsertBulk) SetAPITokenInactivityThresholdDays(v int) *OrganizationUpsertBulk { + return u.Update(func(s *OrganizationUpsert) { + s.SetAPITokenInactivityThresholdDays(v) + }) +} + +// AddAPITokenInactivityThresholdDays adds v to the "api_token_inactivity_threshold_days" field. +func (u *OrganizationUpsertBulk) AddAPITokenInactivityThresholdDays(v int) *OrganizationUpsertBulk { + return u.Update(func(s *OrganizationUpsert) { + s.AddAPITokenInactivityThresholdDays(v) + }) +} + +// UpdateAPITokenInactivityThresholdDays sets the "api_token_inactivity_threshold_days" field to the value that was provided on create. +func (u *OrganizationUpsertBulk) UpdateAPITokenInactivityThresholdDays() *OrganizationUpsertBulk { + return u.Update(func(s *OrganizationUpsert) { + s.UpdateAPITokenInactivityThresholdDays() + }) +} + +// ClearAPITokenInactivityThresholdDays clears the value of the "api_token_inactivity_threshold_days" field. +func (u *OrganizationUpsertBulk) ClearAPITokenInactivityThresholdDays() *OrganizationUpsertBulk { + return u.Update(func(s *OrganizationUpsert) { + s.ClearAPITokenInactivityThresholdDays() + }) +} + // Exec executes the query. func (u *OrganizationUpsertBulk) Exec(ctx context.Context) error { if u.create.err != nil { diff --git a/app/controlplane/pkg/data/ent/organization_update.go b/app/controlplane/pkg/data/ent/organization_update.go index 0f3ba2d96..c0451eb84 100644 --- a/app/controlplane/pkg/data/ent/organization_update.go +++ b/app/controlplane/pkg/data/ent/organization_update.go @@ -147,6 +147,33 @@ func (_u *OrganizationUpdate) SetNillableRestrictContractCreationToOrgAdmins(v * return _u } +// SetAPITokenInactivityThresholdDays sets the "api_token_inactivity_threshold_days" field. +func (_u *OrganizationUpdate) SetAPITokenInactivityThresholdDays(v int) *OrganizationUpdate { + _u.mutation.ResetAPITokenInactivityThresholdDays() + _u.mutation.SetAPITokenInactivityThresholdDays(v) + return _u +} + +// SetNillableAPITokenInactivityThresholdDays sets the "api_token_inactivity_threshold_days" field if the given value is not nil. +func (_u *OrganizationUpdate) SetNillableAPITokenInactivityThresholdDays(v *int) *OrganizationUpdate { + if v != nil { + _u.SetAPITokenInactivityThresholdDays(*v) + } + return _u +} + +// AddAPITokenInactivityThresholdDays adds value to the "api_token_inactivity_threshold_days" field. +func (_u *OrganizationUpdate) AddAPITokenInactivityThresholdDays(v int) *OrganizationUpdate { + _u.mutation.AddAPITokenInactivityThresholdDays(v) + return _u +} + +// ClearAPITokenInactivityThresholdDays clears the value of the "api_token_inactivity_threshold_days" field. +func (_u *OrganizationUpdate) ClearAPITokenInactivityThresholdDays() *OrganizationUpdate { + _u.mutation.ClearAPITokenInactivityThresholdDays() + return _u +} + // AddMembershipIDs adds the "memberships" edge to the Membership entity by IDs. func (_u *OrganizationUpdate) AddMembershipIDs(ids ...uuid.UUID) *OrganizationUpdate { _u.mutation.AddMembershipIDs(ids...) @@ -514,6 +541,15 @@ func (_u *OrganizationUpdate) sqlSave(ctx context.Context) (_node int, err error if value, ok := _u.mutation.RestrictContractCreationToOrgAdmins(); ok { _spec.SetField(organization.FieldRestrictContractCreationToOrgAdmins, field.TypeBool, value) } + if value, ok := _u.mutation.APITokenInactivityThresholdDays(); ok { + _spec.SetField(organization.FieldAPITokenInactivityThresholdDays, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedAPITokenInactivityThresholdDays(); ok { + _spec.AddField(organization.FieldAPITokenInactivityThresholdDays, field.TypeInt, value) + } + if _u.mutation.APITokenInactivityThresholdDaysCleared() { + _spec.ClearField(organization.FieldAPITokenInactivityThresholdDays, field.TypeInt) + } if _u.mutation.MembershipsCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -1004,6 +1040,33 @@ func (_u *OrganizationUpdateOne) SetNillableRestrictContractCreationToOrgAdmins( return _u } +// SetAPITokenInactivityThresholdDays sets the "api_token_inactivity_threshold_days" field. +func (_u *OrganizationUpdateOne) SetAPITokenInactivityThresholdDays(v int) *OrganizationUpdateOne { + _u.mutation.ResetAPITokenInactivityThresholdDays() + _u.mutation.SetAPITokenInactivityThresholdDays(v) + return _u +} + +// SetNillableAPITokenInactivityThresholdDays sets the "api_token_inactivity_threshold_days" field if the given value is not nil. +func (_u *OrganizationUpdateOne) SetNillableAPITokenInactivityThresholdDays(v *int) *OrganizationUpdateOne { + if v != nil { + _u.SetAPITokenInactivityThresholdDays(*v) + } + return _u +} + +// AddAPITokenInactivityThresholdDays adds value to the "api_token_inactivity_threshold_days" field. +func (_u *OrganizationUpdateOne) AddAPITokenInactivityThresholdDays(v int) *OrganizationUpdateOne { + _u.mutation.AddAPITokenInactivityThresholdDays(v) + return _u +} + +// ClearAPITokenInactivityThresholdDays clears the value of the "api_token_inactivity_threshold_days" field. +func (_u *OrganizationUpdateOne) ClearAPITokenInactivityThresholdDays() *OrganizationUpdateOne { + _u.mutation.ClearAPITokenInactivityThresholdDays() + return _u +} + // AddMembershipIDs adds the "memberships" edge to the Membership entity by IDs. func (_u *OrganizationUpdateOne) AddMembershipIDs(ids ...uuid.UUID) *OrganizationUpdateOne { _u.mutation.AddMembershipIDs(ids...) @@ -1401,6 +1464,15 @@ func (_u *OrganizationUpdateOne) sqlSave(ctx context.Context) (_node *Organizati if value, ok := _u.mutation.RestrictContractCreationToOrgAdmins(); ok { _spec.SetField(organization.FieldRestrictContractCreationToOrgAdmins, field.TypeBool, value) } + if value, ok := _u.mutation.APITokenInactivityThresholdDays(); ok { + _spec.SetField(organization.FieldAPITokenInactivityThresholdDays, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedAPITokenInactivityThresholdDays(); ok { + _spec.AddField(organization.FieldAPITokenInactivityThresholdDays, field.TypeInt, value) + } + if _u.mutation.APITokenInactivityThresholdDaysCleared() { + _spec.ClearField(organization.FieldAPITokenInactivityThresholdDays, field.TypeInt) + } if _u.mutation.MembershipsCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, diff --git a/app/controlplane/pkg/data/ent/schema/organization.go b/app/controlplane/pkg/data/ent/schema/organization.go index 0c2367a4e..a6f5cead5 100644 --- a/app/controlplane/pkg/data/ent/schema/organization.go +++ b/app/controlplane/pkg/data/ent/schema/organization.go @@ -55,6 +55,9 @@ func (Organization) Fields() []ent.Field { field.Bool("prevent_implicit_workflow_creation").Default(false), // restrict_contract_creation_to_org_admins restricts contract creation (org-level and project-level) to only organization admins field.Bool("restrict_contract_creation_to_org_admins").Default(false), + // api_token_inactivity_threshold_days is the number of days after which inactive API tokens are auto-revoked. + // nil = disabled, any positive int = threshold in days + field.Int("api_token_inactivity_threshold_days").Optional().Nillable(), } } diff --git a/app/controlplane/pkg/data/organization.go b/app/controlplane/pkg/data/organization.go index 6c349d1d6..7f658af78 100644 --- a/app/controlplane/pkg/data/organization.go +++ b/app/controlplane/pkg/data/organization.go @@ -1,5 +1,5 @@ // -// Copyright 2023-2025 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -77,7 +77,7 @@ func (r *OrganizationRepo) FindByName(ctx context.Context, name string) (*biz.Or return entOrgToBizOrg(org), nil } -func (r *OrganizationRepo) Update(ctx context.Context, id uuid.UUID, blockOnPolicyViolation *bool, policiesAllowedHostnames []string, preventImplicitWorkflowCreation *bool, restrictContractCreationToOrgAdmins *bool) (*biz.Organization, error) { +func (r *OrganizationRepo) Update(ctx context.Context, id uuid.UUID, blockOnPolicyViolation *bool, policiesAllowedHostnames []string, preventImplicitWorkflowCreation *bool, restrictContractCreationToOrgAdmins *bool, apiTokenInactivityThresholdDays *int) (*biz.Organization, error) { opts := r.data.DB.Organization.UpdateOneID(id). Where(organization.DeletedAtIsNil()). SetNillableBlockOnPolicyViolation(blockOnPolicyViolation). @@ -89,6 +89,14 @@ func (r *OrganizationRepo) Update(ctx context.Context, id uuid.UUID, blockOnPoli opts.SetPoliciesAllowedHostnames(policiesAllowedHostnames) } + if apiTokenInactivityThresholdDays != nil { + if *apiTokenInactivityThresholdDays == 0 { + opts.ClearAPITokenInactivityThresholdDays() + } else { + opts.SetAPITokenInactivityThresholdDays(*apiTokenInactivityThresholdDays) + } + } + org, err := opts.Save(ctx) if err != nil { return nil, fmt.Errorf("failed to update organization: %w", err) @@ -97,6 +105,25 @@ func (r *OrganizationRepo) Update(ctx context.Context, id uuid.UUID, blockOnPoli return r.FindByID(ctx, org.ID) } +// FindWithTokenInactivityThreshold returns organizations that have the api_token_inactivity_threshold_days set. +func (r *OrganizationRepo) FindWithTokenInactivityThreshold(ctx context.Context) ([]*biz.Organization, error) { + orgs, err := r.data.DB.Organization.Query(). + Where( + organization.APITokenInactivityThresholdDaysNotNil(), + organization.DeletedAtIsNil(), + ).All(ctx) + if err != nil { + return nil, fmt.Errorf("failed to query organizations with token inactivity threshold: %w", err) + } + + result := make([]*biz.Organization, 0, len(orgs)) + for _, org := range orgs { + result = append(result, entOrgToBizOrg(org)) + } + + return result, nil +} + // Delete soft-deletes an organization by ID. func (r *OrganizationRepo) Delete(ctx context.Context, id uuid.UUID) error { return r.data.DB.Organization.UpdateOneID(id). @@ -115,5 +142,6 @@ func entOrgToBizOrg(eu *ent.Organization) *biz.Organization { PoliciesAllowedHostnames: eu.PoliciesAllowedHostnames, PreventImplicitWorkflowCreation: eu.PreventImplicitWorkflowCreation, RestrictContractCreationToOrgAdmins: eu.RestrictContractCreationToOrgAdmins, + APITokenInactivityThresholdDays: eu.APITokenInactivityThresholdDays, } }