Enforce PAT expiry for free-tier service accounts

Summary

Per the machine identity design document, free tier service accounts must always have PAT expiration enforced (unlike Premium/Ultimate where it's configurable by admins/group owners).

This MR adds the enforcement to ensure free-tier users cannot create non-expiring tokens for their service accounts.

Note: This MR is a blocker before the service_accounts_available_on_free_or_unlicensed feature flag can be enabled. See !225913 (merged) for the main enablement MR.

Changes

  • Authn::ServiceAccounts.free_tier?: New method (self-managed) - returns true when SAs are available only via the feature flag (not via paid license)
  • Authn::ServiceAccounts.free_tier_namespace?: New method (SaaS) - returns true when namespace is on free tier but has SAs via the feature flag
  • ServiceAccountTokenValidator#expiry_enforced?: Now always returns true for free tier, regardless of admin/group settings

Behavior

Tier PAT Expiry Configurable?
Premium/Ultimate Default on, admin/group owner can disable Yes
Free + FF Always on No
Trial Follows paid path Yes

How to validate

Prerequisites

  • GDK running with EE license

Self-Managed (Free/Unlicensed Instance)

# 1. Open Rails console
bundle exec rails console

# 2. Enable the feature flag
Feature.enable(:service_accounts_available_on_free_or_unlicensed)

# 3. Verify no license or use starter license
puts "License: #{License.current&.plan}"

# 4. Disable the expiry setting at instance level
ApplicationSetting.current.update!(service_access_tokens_expiration_enforced: false)

# 5. Create or find a service account
sa = User.service_accounts.first

# 6. Verify free_tier? detection
puts "free_tier?: #{Authn::ServiceAccounts.free_tier?}"
# Expected: true

# 7. Test the validator - should ALWAYS return true on free tier
validator = EE::Gitlab::PersonalAccessTokens::ServiceAccountTokenValidator.new(sa)
puts "expiry_enforced?: #{validator.expiry_enforced?}"
# Expected: true (regardless of the setting being false)

SaaS (Free-Tier Namespace)

# 1. Open Rails console with SaaS enabled
GITLAB_SIMULATE_SAAS=1 bundle exec rails console

# 2. Enable namespace plan checking (required for SaaS simulation)
ApplicationSetting.current.update!(check_namespace_plan: true)

# 3. Get your group
group = Group.find_by_path('<your-group-path>')

# 4. Remove subscription to simulate free tier
group.gitlab_subscription&.destroy
group.clear_memoization(:features_available_in_plan)
group.clear_memoization(:licensed_feature_available)
group.reload

# 5. Enable the feature flag for this group
Feature.enable(:service_accounts_available_on_free_or_unlicensed, group)

# 6. Verify free tier detection
puts "feature_available_non_trial?: #{group.feature_available_non_trial?(:service_accounts)}"
# Expected: false

puts "free_tier_namespace?: #{Authn::ServiceAccounts.free_tier_namespace?(group)}"
# Expected: true

# 7. Get or create a service account provisioned by this group
sa = User.service_accounts.where(provisioned_by_group_id: group.id).first

# 8. Disable the group-level expiry setting
group.namespace_settings.update!(service_access_tokens_expiration_enforced: false)

# 9. Test the validator - should ALWAYS return true on free tier
validator = EE::Gitlab::PersonalAccessTokens::ServiceAccountTokenValidator.new(sa)
puts "expiry_enforced?: #{validator.expiry_enforced?}"
# Expected: true (regardless of the group setting being false)

Compare with Paid Namespace (SaaS)

# 1. Create a premium subscription
GitlabSubscription.create!(
  namespace: group,
  hosted_plan: Plan.find_by(name: 'premium'),
  seats: 10,
  start_date: Date.today,
  end_date: 1.year.from_now
)

# 2. Clear memoization cache
group.clear_memoization(:features_available_in_plan)
group.clear_memoization(:licensed_feature_available)
group.reload

# 3. Verify NOT free tier
puts "free_tier_namespace?: #{Authn::ServiceAccounts.free_tier_namespace?(group)}"
# Expected: false

# 4. Disable expiry setting and verify it's respected for paid tier
group.namespace_settings.update!(service_access_tokens_expiration_enforced: false)
validator = EE::Gitlab::PersonalAccessTokens::ServiceAccountTokenValidator.new(sa)
puts "expiry_enforced?: #{validator.expiry_enforced?}"
# Expected: false (respects group setting for paid tier)

Expected Behavior Summary

Tier PAT Expiry Setting expiry_enforced?
Premium/Ultimate ON true
Premium/Ultimate OFF false (respects setting)
Free + FF enabled ON true
Free + FF enabled OFF true (always enforced)
Trial OFF false (follows paid path)

Running the Specs

bundle exec rspec ee/spec/lib/authn/service_accounts_spec.rb \
  ee/spec/lib/ee/gitlab/personal_access_tokens/service_account_token_validator_spec.rb \
  --format documentation
Edited by Smriti Garg

Merge request reports

Loading