false === self::isPending($role), )); } /** * The markers on an account: administrative roles the CRM claims, awaiting approval. * * @param string[] $roles * * @return string[] */ public static function pendingOnly(array $roles): array { return array_values(array_filter($roles, static fn (string $role): bool => self::isPending($role))); } /** * The roles behind those markers, labelled — what an approver acts on. * * @param string[] $roles * * @return array role => label */ public static function nominatedFrom(array $roles): array { $labels = self::labels(); $nominated = []; foreach (self::pendingOnly($roles) as $marker) { $role = self::realRole($marker); if (\in_array($role, self::ADMINISTRATIVE, true)) { $nominated[$role] = $labels[$role]; } } return $nominated; } /** * The whole policy, applied on every login. * * 1. revoke everything the CRM no longer claims — granted roles and markers alike, which is * what makes BusPro the source of truth; * 2. grant the unconditional roles it claims; * 3. mark every administrative role it claims that is not granted already. This runs after * the revocation, so a role just revoked is not immediately marked again, and an approved * role is never marked a second time; * 4. fall back to ROLE_CUSTOMER when nothing effective is left. * * Nothing here can raise a privilege: step 3 only ever produces markers. * * @param string[] $storedRoles * @param string[] $claimedRoles what the CRM reports, plus the roles derived from the account * itself (ROLE_EMPLOYEE); anything outside self::ALL is ignored * * @return string[] */ public static function sync(array $storedRoles, array $claimedRoles): array { $claimed = array_values(array_intersect(self::ALL, array_unique($claimedRoles))); $roles = array_values(array_filter( self::assignedOnly($storedRoles), static fn (string $role): bool => \in_array(self::realRole($role), $claimed, true), )); foreach (array_intersect($claimed, self::UNCONDITIONAL) as $role) { $roles[] = $role; } foreach (array_intersect($claimed, self::ADMINISTRATIVE) as $role) { if (false === \in_array($role, $roles, true)) { $roles[] = self::pending($role); } } return self::withCustomerFallback(array_values(array_unique($roles))); } /** * Turns a marker into the role it stands for. Refuses anything the account is not nominated * for, so neither a hand-crafted request nor a claim revoked while the confirmation dialog * was open can grant a role the CRM never reported. * * @param string[] $storedRoles * * @return string[] * * @throws \InvalidArgumentException when $role is not pending on this account */ public static function approve(array $storedRoles, string $role): array { $roles = self::assignedOnly($storedRoles); if (false === \in_array(self::pending($role), $roles, true)) { throw new \InvalidArgumentException(sprintf('The role "%s" is not pending approval.', $role)); } $roles = array_diff($roles, [self::pending($role)]); $roles[] = $role; return self::withCustomerFallback(array_values(array_unique($roles))); } /** * Everybody without an effective role is a customer, and nobody with one is. The fallback is * a fallback, not a baseline — ROLE_CUSTOMER and ROLE_TEAMER are mutually exclusive on * purpose. * * @param string[] $roles * * @return string[] */ private static function withCustomerFallback(array $roles): array { if ([] === array_diff(self::effectiveOnly($roles), [self::CUSTOMER])) { return array_values(array_unique([...$roles, self::CUSTOMER])); } return array_values(array_diff($roles, [self::CUSTOMER])); } private static function isPending(string $role): bool { return str_ends_with($role, self::PENDING_SUFFIX); } private static function realRole(string $role): string { return self::isPending($role) ? substr($role, 0, -\strlen(self::PENDING_SUFFIX)) : $role; } /** * @return array role => label */ public static function labels(): array { $labels = [ self::ADMIN => 'Administration', self::MANAGER => 'Manager:in', self::TEAMER => 'Teamer:in', self::CUSTOMER => 'Kund:in', self::HOUSE_MANAGER => 'Hausleitung', self::GROUPS_ADMIN => 'Preisrechner Admin', self::GROUPS_MANAGER => 'Preisrechner', self::EMPLOYEE => 'Mitarbeiter:in', ]; foreach (self::ADMINISTRATIVE as $role) { $labels[self::pending($role)] = $labels[$role].' (nicht freigeschaltet)'; } return $labels; } }