|
| 1 | +<?php |
| 2 | + |
| 3 | +declare(strict_types=1); |
| 4 | + |
| 5 | +/** |
| 6 | + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors |
| 7 | + * SPDX-License-Identifier: AGPL-3.0-or-later |
| 8 | + */ |
| 9 | + |
| 10 | +namespace NCU\Actor; |
| 11 | + |
| 12 | +use InvalidArgumentException; |
| 13 | +use JsonSerializable; |
| 14 | +use OCP\AppFramework\Attribute\Consumable; |
| 15 | +use Override; |
| 16 | + |
| 17 | +/** |
| 18 | + * Transport information about whose authority is being claimed to perform an |
| 19 | + * operation ($principal) and, if someone else is exercising it, who that is |
| 20 | + * ($delegate), recursively. |
| 21 | + * |
| 22 | + * Creating an Actor asserts nothing. Callers check whether the operation is |
| 23 | + * permitted before creating it. Consumers receiving an Actor from another app, |
| 24 | + * a job payload or a request, check the principal's permissions themselves. |
| 25 | + * |
| 26 | + * Examples: |
| 27 | + * bot "summarizer" for alice: principal=alice, delegate=Actor(bot), via=OnBehalfOf |
| 28 | + * admin impersonating alice: principal=alice, delegate=Actor(admin), via=Impersonation |
| 29 | + * alice's action escalated: principal=system, delegate=Actor(alice), via=SystemElevation |
| 30 | + * |
| 31 | + * @psalm-immutable |
| 32 | + * @experimental 36.0.0 |
| 33 | + */ |
| 34 | +#[Consumable(since: '36.0.0')] |
| 35 | +final readonly class Actor implements JsonSerializable { |
| 36 | + /** |
| 37 | + * @experimental 36.0.0 |
| 38 | + */ |
| 39 | + public const int MAX_CHAIN_LENGTH = 4; |
| 40 | + |
| 41 | + private function __construct( |
| 42 | + public Principal $principal, |
| 43 | + public ?self $delegate = null, |
| 44 | + public ?DelegationKind $via = null, |
| 45 | + ) { |
| 46 | + if (($delegate === null) !== ($via === null)) { |
| 47 | + throw new InvalidArgumentException('A delegate requires a delegation kind and vice versa'); |
| 48 | + } |
| 49 | + |
| 50 | + if ($delegate !== null && $via !== null) { |
| 51 | + self::assertValidLink($principal, $delegate, $via); |
| 52 | + } |
| 53 | + |
| 54 | + $chain = $this->chain(); |
| 55 | + if (count($chain) > self::MAX_CHAIN_LENGTH) { |
| 56 | + throw new InvalidArgumentException('Delegation chain too long'); |
| 57 | + } |
| 58 | + |
| 59 | + $keys = array_map(static fn (Principal $p) => $p->key(), $chain); |
| 60 | + if (count($keys) !== count(array_unique($keys))) { |
| 61 | + throw new InvalidArgumentException('Principal appears more than once in delegation chain'); |
| 62 | + } |
| 63 | + } |
| 64 | + |
| 65 | + /** |
| 66 | + * Actor with no delegation. |
| 67 | + * |
| 68 | + * @experimental 36.0.0 |
| 69 | + */ |
| 70 | + public static function of(Principal $principal): self { |
| 71 | + return new self($principal); |
| 72 | + } |
| 73 | + |
| 74 | + /** |
| 75 | + * $this acts as $target with the target's full view. |
| 76 | + * |
| 77 | + * @experimental 36.0.0 |
| 78 | + */ |
| 79 | + public function impersonate(Principal $target): self { |
| 80 | + return new self($target, $this, DelegationKind::Impersonation); |
| 81 | + } |
| 82 | + |
| 83 | + /** |
| 84 | + * $this acts on behalf of $subject. |
| 85 | + * |
| 86 | + * @experimental 36.0.0 |
| 87 | + */ |
| 88 | + public function actFor(Principal $subject): self { |
| 89 | + return new self($subject, $this, DelegationKind::OnBehalfOf); |
| 90 | + } |
| 91 | + |
| 92 | + /** |
| 93 | + * Continue claiming the authority of a system component. |
| 94 | + * |
| 95 | + * @experimental 36.0.0 |
| 96 | + */ |
| 97 | + public function elevate(Principal $system): self { |
| 98 | + return new self($system, $this, DelegationKind::SystemElevation); |
| 99 | + } |
| 100 | + |
| 101 | + /** |
| 102 | + * @experimental 36.0.0 |
| 103 | + */ |
| 104 | + public function isDelegated(): bool { |
| 105 | + return $this->delegate !== null; |
| 106 | + } |
| 107 | + |
| 108 | + /** |
| 109 | + * The party at the end of the chain that actually started this (bot, admin, user who triggered elevation). |
| 110 | + * |
| 111 | + * @experimental 36.0.0 |
| 112 | + */ |
| 113 | + public function initiator(): Principal { |
| 114 | + return $this->delegate?->initiator() ?? $this->principal; |
| 115 | + } |
| 116 | + |
| 117 | + /** |
| 118 | + * @return non-empty-list<Principal> principal first, initiator last |
| 119 | + * @experimental 36.0.0 |
| 120 | + */ |
| 121 | + public function chain(): array { |
| 122 | + return [$this->principal, ...($this->delegate?->chain() ?? [])]; |
| 123 | + } |
| 124 | + |
| 125 | + /** |
| 126 | + * Whether any link in the chain is of the given type, e.g. "is a bot involved at all?" |
| 127 | + * |
| 128 | + * @experimental 36.0.0 |
| 129 | + */ |
| 130 | + public function involves(PrincipalType $type): bool { |
| 131 | + foreach ($this->chain() as $p) { |
| 132 | + if ($p->is($type)) { |
| 133 | + return true; |
| 134 | + } |
| 135 | + } |
| 136 | + return false; |
| 137 | + } |
| 138 | + |
| 139 | + /** |
| 140 | + * @experimental 36.0.0 |
| 141 | + */ |
| 142 | + #[Override] |
| 143 | + public function jsonSerialize(): array { |
| 144 | + return [ |
| 145 | + 'principal' => $this->principal, |
| 146 | + 'via' => $this->via?->value, |
| 147 | + 'delegate' => $this->delegate, |
| 148 | + ]; |
| 149 | + } |
| 150 | + |
| 151 | + /** |
| 152 | + * Rehydrate from a serialized payload. |
| 153 | + * |
| 154 | + * @param array<array-key, mixed> $data |
| 155 | + * @throws InvalidArgumentException |
| 156 | + * @experimental 36.0.0 |
| 157 | + */ |
| 158 | + public static function fromArray(array $data): self { |
| 159 | + $principal = $data['principal'] ?? null; |
| 160 | + $delegate = $data['delegate'] ?? null; |
| 161 | + $via = $data['via'] ?? null; |
| 162 | + if (!is_array($principal) || ($delegate !== null && !is_array($delegate)) || ($via !== null && !is_string($via))) { |
| 163 | + throw new InvalidArgumentException('Invalid actor data'); |
| 164 | + } |
| 165 | + |
| 166 | + return new self(Principal::fromArray($principal), |
| 167 | + $delegate !== null ? self::fromArray($delegate) : null, |
| 168 | + $via !== null ? DelegationKind::tryFrom($via) ?? throw new InvalidArgumentException(sprintf('Unknown delegation kind "%s"', $via)) |
| 169 | + : null, ); |
| 170 | + } |
| 171 | + |
| 172 | + private static function assertValidLink(Principal $principal, Actor $delegate, DelegationKind $via): void { |
| 173 | + $fail = static fn (string $why): never => throw new InvalidArgumentException( |
| 174 | + sprintf('Invalid %s: %s', $via->value, $why), |
| 175 | + ); |
| 176 | + |
| 177 | + match ($via) { |
| 178 | + DelegationKind::Impersonation => match (true) { |
| 179 | + !$principal->is(PrincipalType::User) => $fail('only local users can be impersonated'), |
| 180 | + !$delegate->principal->is(PrincipalType::User) => $fail('only local users can impersonate'), |
| 181 | + $delegate->isDelegated() => $fail('nested impersonation is not allowed'), |
| 182 | + default => null, |
| 183 | + }, |
| 184 | + DelegationKind::OnBehalfOf => match (true) { |
| 185 | + !$principal->type->isHuman() => $fail('subject must be a human principal'), |
| 186 | + !$delegate->principal->is(PrincipalType::Bot) => $fail('only bots act on behalf of others'), |
| 187 | + default => null, |
| 188 | + }, |
| 189 | + DelegationKind::SystemElevation => match (true) { |
| 190 | + !$principal->is(PrincipalType::System) => $fail('target must be a system principal'), |
| 191 | + $delegate->principal->is(PrincipalType::System) => $fail('system cannot elevate to system'), |
| 192 | + default => null, |
| 193 | + }, |
| 194 | + }; |
| 195 | + } |
| 196 | +} |
0 commit comments