diff --git a/fluxer_admin/src/templates/pages/user_detail_tabs/overview.rs b/fluxer_admin/src/templates/pages/user_detail_tabs/overview.rs index 1442dafde..e08d72db3 100644 --- a/fluxer_admin/src/templates/pages/user_detail_tabs/overview.rs +++ b/fluxer_admin/src/templates/pages/user_detail_tabs/overview.rs @@ -560,6 +560,8 @@ fn traits_form( } } +const DERIVED_TRAITS: [&str; 1] = ["premium"]; + fn parse_trait_definitions(limit_config: Option<&LimitConfigResponse>) -> Vec<&str> { limit_config .map(|response| { @@ -569,6 +571,7 @@ fn parse_trait_definitions(limit_config: Option<&LimitConfigResponse>) -> Vec<&s .iter() .map(|value| value.trim()) .filter(|value| !value.is_empty()) + .filter(|value| !DERIVED_TRAITS.contains(value)) .collect() }) .unwrap_or_default() @@ -579,5 +582,6 @@ fn custom_traits<'a>(user: &'a AdminUser, trait_definitions: &[&str]) -> Vec<&'a .iter() .map(String::as_str) .filter(|trait_name| !trait_definitions.contains(trait_name)) + .filter(|trait_name| !DERIVED_TRAITS.contains(trait_name)) .collect() } diff --git a/fluxer_api/src/api/admin/services/AdminUserSecurityService.ts b/fluxer_api/src/api/admin/services/AdminUserSecurityService.ts index 3d8ab5e68..9f258055a 100644 --- a/fluxer_api/src/api/admin/services/AdminUserSecurityService.ts +++ b/fluxer_api/src/api/admin/services/AdminUserSecurityService.ts @@ -45,6 +45,7 @@ import {Logger} from '../../Logger'; import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons'; import type {IRiskHistoryRepository} from '../../risk/HistoricalOutcomeRepository'; import type {HistoricalOutcomeCode} from '../../risk/RiskHistoryTypes'; +import {resolveAssignedTraits} from '../../user/UserTraits'; import {getIpAddressReverse, getLocationLabelFromIp} from '../../utils/IpUtils'; import {resolveSessionClientInfo} from '../../utils/SessionClientIdentity'; import {mapUserToAdminResponse} from '../models/UserTypes'; @@ -383,7 +384,8 @@ export class AdminUserSecurityService { if (!user) { throw new UnknownUserError(); } - const traitSet = data.traits.length > 0 ? new Set(data.traits) : null; + const assigned = resolveAssignedTraits(user.traits ?? [], data.traits); + const traitSet = assigned.size > 0 ? assigned : null; const updatedUser = await userRepository.patchUpsert( userId, { diff --git a/fluxer_api/src/api/user/UserTraits.test.ts b/fluxer_api/src/api/user/UserTraits.test.ts new file mode 100644 index 000000000..3769a2afe --- /dev/null +++ b/fluxer_api/src/api/user/UserTraits.test.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {describe, expect, it} from 'vitest'; +import {isDerivedTrait, isServerManagedTrait, resolveAssignedTraits} from './UserTraits'; + +describe('UserTraits', () => { + it('treats premium as derived rather than assignable', () => { + expect(isDerivedTrait('premium')).toBe(true); + expect(isDerivedTrait('beta-tester')).toBe(false); + }); + + it('recognises every trait shape the server manages', () => { + expect(isServerManagedTrait('sso')).toBe(true); + expect(isServerManagedTrait('sso:acme')).toBe(true); + expect(isServerManagedTrait('sso_provider:0123456789abcdef')).toBe(true); + expect(isServerManagedTrait('sso_identity:0123456789abcdef')).toBe(true); + expect(isServerManagedTrait('registration_pending_approval')).toBe(true); + expect(isServerManagedTrait('registration_rejected')).toBe(true); + expect(isServerManagedTrait('beta-tester')).toBe(false); + }); + + it('keeps the operator traits it was given', () => { + expect([...resolveAssignedTraits([], ['beta-tester', 'experimental'])]).toEqual(['beta-tester', 'experimental']); + }); + + it('drops premium because nothing reads the stored value', () => { + expect([...resolveAssignedTraits([], ['premium'])]).toEqual([]); + expect([...resolveAssignedTraits([], ['beta-tester', 'premium'])]).toEqual(['beta-tester']); + }); + + it('keeps a premium user premium when the operator saves other traits', () => { + expect([...resolveAssignedTraits(['premium'], ['beta-tester'])]).toEqual(['beta-tester']); + }); + + it('preserves server managed traits the operator did not send', () => { + const resolved = resolveAssignedTraits( + ['sso', 'sso:acme', 'sso_identity:0123456789abcdef', 'beta-tester'], + ['experimental'], + ); + expect([...resolved].sort()).toEqual(['experimental', 'sso', 'sso:acme', 'sso_identity:0123456789abcdef']); + }); + + it('keeps a pending approval user pending when their traits are cleared', () => { + expect([...resolveAssignedTraits(['registration_pending_approval'], [])]).toEqual([ + 'registration_pending_approval', + ]); + }); + + it('refuses to let an operator forge a server managed trait', () => { + expect([...resolveAssignedTraits([], ['sso', 'sso_identity:forged'])]).toEqual([]); + }); + + it('ignores empty trait names', () => { + expect([...resolveAssignedTraits([], ['', 'beta-tester'])]).toEqual(['beta-tester']); + }); +}); diff --git a/fluxer_api/src/api/user/UserTraits.ts b/fluxer_api/src/api/user/UserTraits.ts new file mode 100644 index 000000000..6ec22e28c --- /dev/null +++ b/fluxer_api/src/api/user/UserTraits.ts @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +const DERIVED_TRAITS = new Set(['premium']); + +const SERVER_MANAGED_TRAITS = new Set(['sso', 'registration_pending_approval', 'registration_rejected']); + +const SERVER_MANAGED_TRAIT_PREFIXES = ['sso:', 'sso_provider:', 'sso_identity:']; + +export function isDerivedTrait(trait: string): boolean { + return DERIVED_TRAITS.has(trait); +} + +export function isServerManagedTrait(trait: string): boolean { + return SERVER_MANAGED_TRAITS.has(trait) || SERVER_MANAGED_TRAIT_PREFIXES.some((prefix) => trait.startsWith(prefix)); +} + +export function resolveAssignedTraits(current: Iterable, requested: Iterable): Set { + const next = new Set(); + for (const trait of requested) { + if (!trait || isDerivedTrait(trait) || isServerManagedTrait(trait)) { + continue; + } + next.add(trait); + } + for (const trait of current) { + if (isServerManagedTrait(trait)) { + next.add(trait); + } + } + return next; +}