Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@

return array(
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
'NCU\\Actor\\Actor' => $baseDir . '/lib/unstable/Actor/Actor.php',
'NCU\\Actor\\DelegationKind' => $baseDir . '/lib/unstable/Actor/DelegationKind.php',
'NCU\\Actor\\Principal' => $baseDir . '/lib/unstable/Actor/Principal.php',
'NCU\\Actor\\PrincipalType' => $baseDir . '/lib/unstable/Actor/PrincipalType.php',
'NCU\\Config\\Exceptions\\IncorrectTypeException' => $baseDir . '/lib/unstable/Config/Exceptions/IncorrectTypeException.php',
'NCU\\Config\\Exceptions\\TypeConflictException' => $baseDir . '/lib/unstable/Config/Exceptions/TypeConflictException.php',
'NCU\\Config\\Exceptions\\UnknownKeyException' => $baseDir . '/lib/unstable/Config/Exceptions/UnknownKeyException.php',
Expand Down
4 changes: 4 additions & 0 deletions lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2

public static $classMap = array (
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
'NCU\\Actor\\Actor' => __DIR__ . '/../../..' . '/lib/unstable/Actor/Actor.php',
'NCU\\Actor\\DelegationKind' => __DIR__ . '/../../..' . '/lib/unstable/Actor/DelegationKind.php',
'NCU\\Actor\\Principal' => __DIR__ . '/../../..' . '/lib/unstable/Actor/Principal.php',
'NCU\\Actor\\PrincipalType' => __DIR__ . '/../../..' . '/lib/unstable/Actor/PrincipalType.php',
'NCU\\Config\\Exceptions\\IncorrectTypeException' => __DIR__ . '/../../..' . '/lib/unstable/Config/Exceptions/IncorrectTypeException.php',
'NCU\\Config\\Exceptions\\TypeConflictException' => __DIR__ . '/../../..' . '/lib/unstable/Config/Exceptions/TypeConflictException.php',
'NCU\\Config\\Exceptions\\UnknownKeyException' => __DIR__ . '/../../..' . '/lib/unstable/Config/Exceptions/UnknownKeyException.php',
Expand Down
197 changes: 197 additions & 0 deletions lib/unstable/Actor/Actor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace NCU\Actor;

use InvalidArgumentException;
use JsonSerializable;
use OCP\AppFramework\Attribute\Consumable;
use Override;

/**
* Transport information about whose authority is being claimed to perform an
* operation ($principal) and, if someone else is exercising it, who that is
* ($delegate), recursively.
*
* Creating an Actor asserts nothing. Callers check whether the operation is
* permitted before creating it. Consumers receiving an Actor from another app,
* a job payload or a request, check the principal's permissions themselves.
*
* Examples:
* bot "summarizer" for alice: principal=alice, delegate=Actor(bot), via=OnBehalfOf
* version expiry in alice's files: principal=alice, delegate=Actor(system), via=OnBehalfOf
* admin impersonating alice: principal=alice, delegate=Actor(admin), via=Impersonation
* alice's action escalated: principal=system, delegate=Actor(alice), via=SystemElevation
*
* @psalm-immutable
* @experimental 36.0.0
*/
#[Consumable(since: '36.0.0')]
final readonly class Actor implements JsonSerializable {
/**
* @experimental 36.0.0
*/
public const int MAX_CHAIN_LENGTH = 16;

private function __construct(
public Principal $principal,
public ?self $delegate = null,
public ?DelegationKind $via = null,
) {
if (($delegate === null) !== ($via === null)) {
throw new InvalidArgumentException('A delegate requires a delegation kind and vice versa');
}

if ($delegate !== null && $via !== null) {
self::assertValidLink($principal, $delegate, $via);
}

$chain = $this->chain();
if (count($chain) > self::MAX_CHAIN_LENGTH) {
throw new InvalidArgumentException('Delegation chain too long');
}

$keys = array_map(static fn (Principal $p) => $p->key(), $chain);
if (count($keys) !== count(array_unique($keys))) {
throw new InvalidArgumentException('Principal appears more than once in delegation chain');
}
}

/**
* Actor with no delegation.
*
* @experimental 36.0.0
*/
public static function of(Principal $principal): self {
return new self($principal);
}

/**
* $this acts as $target with the target's full view.
*
* @experimental 36.0.0
*/
public function impersonate(Principal $target): self {
return new self($target, $this, DelegationKind::Impersonation);
}

/**
* $this acts on behalf of $subject.
*
* @experimental 36.0.0
*/
public function actFor(Principal $subject): self {
return new self($subject, $this, DelegationKind::OnBehalfOf);
}

/**
* Continue claiming the authority of a system component.
*
* @experimental 36.0.0
*/
public function elevate(Principal $system): self {
return new self($system, $this, DelegationKind::SystemElevation);
}

/**
* @experimental 36.0.0
*/
public function isDelegated(): bool {
return $this->delegate !== null;
}

/**
* The party at the end of the chain that actually started this (bot, admin, user who triggered elevation).
*
* @experimental 36.0.0
*/
public function initiator(): Principal {
return $this->delegate?->initiator() ?? $this->principal;
}

/**
* @return non-empty-list<Principal> principal first, initiator last
* @experimental 36.0.0
*/
public function chain(): array {
return [$this->principal, ...($this->delegate?->chain() ?? [])];
}

/**
* Whether any link in the chain is of the given type, e.g. "is a bot involved at all?"
*
* @experimental 36.0.0
*/
public function involves(PrincipalType $type): bool {
foreach ($this->chain() as $p) {
if ($p->is($type)) {
return true;
}
}
return false;
}

/**
* @experimental 36.0.0
*/
#[Override]
public function jsonSerialize(): array {
return [
'principal' => $this->principal,
'via' => $this->via?->value,
'delegate' => $this->delegate,
];
}

/**
* Rehydrate from a serialized payload.
*
* @param array<array-key, mixed> $data
* @throws InvalidArgumentException
* @experimental 36.0.0
*/
public static function fromArray(array $data): self {
$principal = $data['principal'] ?? null;
$delegate = $data['delegate'] ?? null;
$via = $data['via'] ?? null;
if (!is_array($principal) || ($delegate !== null && !is_array($delegate)) || ($via !== null && !is_string($via))) {
throw new InvalidArgumentException('Invalid actor data');
}

return new self(Principal::fromArray($principal),
$delegate !== null ? self::fromArray($delegate) : null,
$via !== null ? DelegationKind::tryFrom($via) ?? throw new InvalidArgumentException(sprintf('Unknown delegation kind "%s"', $via))
: null, );
}

private static function assertValidLink(Principal $principal, Actor $delegate, DelegationKind $via): void {
$fail = static fn (string $why): never => throw new InvalidArgumentException(
sprintf('Invalid %s: %s', $via->value, $why),
);

match ($via) {
DelegationKind::Impersonation => match (true) {
!$principal->is(PrincipalType::User) => $fail('only local users can be impersonated'),
!$delegate->principal->is(PrincipalType::User) => $fail('only local users can impersonate'),
$delegate->isDelegated() => $fail('nested impersonation is not allowed'),
default => null,
},
DelegationKind::OnBehalfOf => match (true) {
!$principal->type->isHuman() => $fail('subject must be a human principal'),
!$delegate->principal->is(PrincipalType::Bot) && !$delegate->principal->is(PrincipalType::System) => $fail('only bots and system components act on behalf of others'),
default => null,
},
DelegationKind::SystemElevation => match (true) {
!$principal->is(PrincipalType::System) => $fail('target must be a system principal'),
$delegate->principal->is(PrincipalType::System) => $fail('system cannot elevate to system'),
default => null,
},
};
}
}
41 changes: 41 additions & 0 deletions lib/unstable/Actor/DelegationKind.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace NCU\Actor;

use OCP\AppFramework\Attribute\Consumable;

/**
* How the delegate came to claim the principal's authority.
* New cases may be added in future versions. Consumers MUST handle unknown
* cases, e.g. with a default branch that denies.
*
* @experimental 36.0.0
*/
#[Consumable(since: '36.0.0')]
enum DelegationKind: string {
/**
* A user acting as another user.
*
* @experimental 36.0.0
*/
case Impersonation = 'impersonation';
/**
* Bot or system component acting for a human.
*
* @experimental 36.0.0
*/
case OnBehalfOf = 'on_behalf_of';
/**
* Code deliberately escalating to a system component.
*
* @experimental 36.0.0
*/
case SystemElevation = 'system_elevation';
}
Loading
Loading