Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.90% covered (warning)
89.90%
89 / 99
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExpirationRulesPolicy
89.90% covered (warning)
89.90%
89 / 99
62.50% covered (warning)
62.50%
5 / 8
27.75
0.00% covered (danger)
0.00%
0 / 1
 keys
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 buildDelegableNonNegativeIntPolicy
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 buildDelegablePositiveIntPolicy
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 buildDelegableIntPolicy
83.78% covered (warning)
83.78%
31 / 37
0.00% covered (danger)
0.00%
0 / 1
11.52
 normalizeNonNegativeInt
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 normalizePositiveInt
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 parseInt
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
8.32
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service\Policy\Provider\ExpirationRules;
10
11use OCA\Libresign\Service\Policy\Contract\IPolicyDefinition;
12use OCA\Libresign\Service\Policy\Contract\IPolicyDefinitionProvider;
13use OCA\Libresign\Service\Policy\Model\PolicyContext;
14use OCA\Libresign\Service\Policy\Model\PolicyLayer;
15use OCA\Libresign\Service\Policy\Model\PolicySpec;
16use OCA\Libresign\Service\Policy\Provider\Helper\DelegationLayerHelper;
17use OCA\Libresign\Service\Policy\Provider\Helper\PolicyKeyNormalizer;
18
19final class ExpirationRulesPolicy implements IPolicyDefinitionProvider {
20    public const KEY_MAXIMUM_VALIDITY = 'maximum_validity';
21    public const KEY_RENEWAL_INTERVAL = 'renewal_interval';
22    public const KEY_EXPIRY_IN_DAYS = 'expiry_in_days';
23
24    public const DEFAULT_MAXIMUM_VALIDITY = 0;
25    public const DEFAULT_RENEWAL_INTERVAL = 0;
26    public const DEFAULT_EXPIRY_IN_DAYS = 365;
27
28    #[\Override]
29    public function keys(): array {
30        return [
31            self::KEY_MAXIMUM_VALIDITY,
32            self::KEY_RENEWAL_INTERVAL,
33            self::KEY_EXPIRY_IN_DAYS,
34        ];
35    }
36
37    #[\Override]
38    public function get(string|\BackedEnum $policyKey): IPolicyDefinition {
39        $normalizedKey = PolicyKeyNormalizer::normalize($policyKey);
40
41        return match ($normalizedKey) {
42            self::KEY_MAXIMUM_VALIDITY => $this->buildDelegableNonNegativeIntPolicy(
43                self::KEY_MAXIMUM_VALIDITY,
44                self::DEFAULT_MAXIMUM_VALIDITY,
45                compositeChildren: [self::KEY_RENEWAL_INTERVAL],
46            ),
47            self::KEY_RENEWAL_INTERVAL => $this->buildDelegableNonNegativeIntPolicy(
48                self::KEY_RENEWAL_INTERVAL,
49                self::DEFAULT_RENEWAL_INTERVAL,
50                helper: true,
51                parentPolicyKey: self::KEY_MAXIMUM_VALIDITY,
52            ),
53            self::KEY_EXPIRY_IN_DAYS => $this->buildDelegablePositiveIntPolicy(
54                self::KEY_EXPIRY_IN_DAYS,
55                self::DEFAULT_EXPIRY_IN_DAYS,
56            ),
57            default => throw new \InvalidArgumentException('Unknown policy key: ' . $normalizedKey),
58        };
59    }
60
61    private function buildDelegableNonNegativeIntPolicy(
62        string $key,
63        int $defaultValue,
64        array $compositeChildren = [],
65        bool $helper = false,
66        ?string $parentPolicyKey = null,
67    ): PolicySpec {
68        return $this->buildDelegableIntPolicy(
69            key: $key,
70            defaultValue: $defaultValue,
71            normalizer: static fn (mixed $rawValue): int => self::normalizeNonNegativeInt($rawValue, $defaultValue),
72            supportsUserPreference: false,
73            supportsRequestOverride: false,
74            compositeChildren: $compositeChildren,
75            helper: $helper,
76            parentPolicyKey: $parentPolicyKey,
77        );
78    }
79
80    private function buildDelegablePositiveIntPolicy(
81        string $key,
82        int $defaultValue,
83        array $compositeChildren = [],
84        bool $helper = false,
85        ?string $parentPolicyKey = null,
86    ): PolicySpec {
87        return $this->buildDelegableIntPolicy(
88            key: $key,
89            defaultValue: $defaultValue,
90            normalizer: static fn (mixed $rawValue): int => self::normalizePositiveInt($rawValue, $defaultValue),
91            supportsUserPreference: false,
92            supportsRequestOverride: false,
93            compositeChildren: $compositeChildren,
94            helper: $helper,
95            parentPolicyKey: $parentPolicyKey,
96        );
97    }
98
99    private function buildDelegableIntPolicy(
100        string $key,
101        int $defaultValue,
102        \Closure $normalizer,
103        bool $supportsRequestOverride,
104        bool $supportsUserPreference = true,
105        array $compositeChildren = [],
106        bool $helper = false,
107        ?string $parentPolicyKey = null,
108    ): PolicySpec {
109        return new PolicySpec(
110            key: $key,
111            defaultSystemValue: $defaultValue,
112            allowedValues: [],
113            normalizer: $normalizer,
114            appConfigKey: $key,
115            supportsUserPreference: $supportsUserPreference,
116            supportsRequestOverride: $supportsRequestOverride,
117            requestLifecycle: PolicySpec::LIFECYCLE_RUNTIME,
118            groupPolicyManager: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool {
119                $actorRole = $context->getActorRole();
120                if ($actorRole->canManageSystemPolicies) {
121                    return true;
122                }
123
124                if (!$actorRole->canManageGroupPolicies) {
125                    return false;
126                }
127
128                if ($actorRole->manageableGroupCount < 1) {
129                    return false;
130                }
131
132                return DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)
133                    || DelegationLayerHelper::hasSystemCreatedGroupDelegation($groupLayers);
134            },
135            systemCreatedGroupRuleEditor: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool {
136                $actorRole = $context->getActorRole();
137                if ($actorRole->canManageSystemPolicies) {
138                    return true;
139                }
140
141                if (!$actorRole->canManageGroupPolicies) {
142                    return false;
143                }
144
145                if (!$existingPolicy->isVisibleToChild() || !$existingPolicy->isAllowChildOverride() || $existingPolicy->getValue() === null) {
146                    return false;
147                }
148
149                if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) {
150                    return true;
151                }
152
153                return $existingPolicy->isCreatedBySystemAdmin();
154            },
155            supportsGroupAdminDelegation: true,
156            compositeChildren: $compositeChildren,
157            helper: $helper,
158            parentPolicyKey: $parentPolicyKey,
159        );
160    }
161
162    private static function normalizeNonNegativeInt(mixed $rawValue, int $fallback): int {
163        $parsed = self::parseInt($rawValue);
164        if ($parsed === null) {
165            return $fallback;
166        }
167
168        return max(0, $parsed);
169    }
170
171    private static function normalizePositiveInt(mixed $rawValue, int $fallback): int {
172        $parsed = self::parseInt($rawValue);
173        if ($parsed === null || $parsed <= 0) {
174            return $fallback;
175        }
176
177        return $parsed;
178    }
179
180    private static function parseInt(mixed $rawValue): ?int {
181        if (is_int($rawValue)) {
182            return $rawValue;
183        }
184
185        if (is_float($rawValue) && is_finite($rawValue)) {
186            return (int)$rawValue;
187        }
188
189        if (is_string($rawValue)) {
190            $trimmed = trim($rawValue);
191            if ($trimmed === '' || !is_numeric($trimmed)) {
192                return null;
193            }
194
195            return (int)$trimmed;
196        }
197
198        return null;
199    }
200
201}