Skip to content

Commit 9cbcae1

Browse files
committed
feat: introduce Actor data object in NCU
Signed-off-by: Salvatore Martire <4652631+salmart-dev@users.noreply.github.com>
1 parent 406c29b commit 9cbcae1

8 files changed

Lines changed: 706 additions & 0 deletions

File tree

‎lib/composer/composer/autoload_classmap.php‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77

88
return array(
99
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
10+
'NCU\\Actor\\Actor' => $baseDir . '/lib/unstable/Actor/Actor.php',
11+
'NCU\\Actor\\DelegationKind' => $baseDir . '/lib/unstable/Actor/DelegationKind.php',
12+
'NCU\\Actor\\Principal' => $baseDir . '/lib/unstable/Actor/Principal.php',
13+
'NCU\\Actor\\PrincipalType' => $baseDir . '/lib/unstable/Actor/PrincipalType.php',
1014
'NCU\\Config\\Exceptions\\IncorrectTypeException' => $baseDir . '/lib/unstable/Config/Exceptions/IncorrectTypeException.php',
1115
'NCU\\Config\\Exceptions\\TypeConflictException' => $baseDir . '/lib/unstable/Config/Exceptions/TypeConflictException.php',
1216
'NCU\\Config\\Exceptions\\UnknownKeyException' => $baseDir . '/lib/unstable/Config/Exceptions/UnknownKeyException.php',

‎lib/composer/composer/autoload_static.php‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
4848

4949
public static $classMap = array (
5050
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
51+
'NCU\\Actor\\Actor' => __DIR__ . '/../../..' . '/lib/unstable/Actor/Actor.php',
52+
'NCU\\Actor\\DelegationKind' => __DIR__ . '/../../..' . '/lib/unstable/Actor/DelegationKind.php',
53+
'NCU\\Actor\\Principal' => __DIR__ . '/../../..' . '/lib/unstable/Actor/Principal.php',
54+
'NCU\\Actor\\PrincipalType' => __DIR__ . '/../../..' . '/lib/unstable/Actor/PrincipalType.php',
5155
'NCU\\Config\\Exceptions\\IncorrectTypeException' => __DIR__ . '/../../..' . '/lib/unstable/Config/Exceptions/IncorrectTypeException.php',
5256
'NCU\\Config\\Exceptions\\TypeConflictException' => __DIR__ . '/../../..' . '/lib/unstable/Config/Exceptions/TypeConflictException.php',
5357
'NCU\\Config\\Exceptions\\UnknownKeyException' => __DIR__ . '/../../..' . '/lib/unstable/Config/Exceptions/UnknownKeyException.php',

‎lib/unstable/Actor/Actor.php‎

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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 OCP\AppFramework\Attribute\Consumable;
13+
14+
/**
15+
* How the delegate came to claim the principal's authority.
16+
*
17+
* @experimental 36.0.0
18+
*/
19+
#[Consumable(since: '36.0.0')]
20+
enum DelegationKind: string {
21+
/**
22+
* A user acting as another user.
23+
*
24+
* @experimental 36.0.0
25+
*/
26+
case Impersonation = 'impersonation';
27+
/**
28+
* Bot acting for a human.
29+
*
30+
* @experimental 36.0.0
31+
*/
32+
case OnBehalfOf = 'on_behalf_of';
33+
/**
34+
* Code deliberately escalating to a system component.
35+
*
36+
* @experimental 36.0.0
37+
*/
38+
case SystemElevation = 'system_elevation';
39+
}

0 commit comments

Comments
 (0)