From 97bc8eb2bb24cbfdcfea3fd664c802b1a571bd0a Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sat, 2 Aug 2025 14:44:29 +0200 Subject: [PATCH 01/10] send from event to entity calls Signed-off-by: Robert Landers --- src/EntityContext.php | 82 +++++++++++++++++++++++------------ src/Events/RevokeUser.php | 2 +- src/Events/ShareOwnership.php | 5 ++- src/Events/ShareWithRole.php | 2 +- src/Events/ShareWithUser.php | 2 +- src/Events/WithFrom.php | 28 ++++++++++++ 6 files changed, 90 insertions(+), 31 deletions(-) create mode 100644 src/Events/WithFrom.php diff --git a/src/EntityContext.php b/src/EntityContext.php index fae80d18..153239d8 100644 --- a/src/EntityContext.php +++ b/src/EntityContext.php @@ -24,6 +24,7 @@ namespace Bottledcode\DurablePhp; +use Bottledcode\DurablePhp\Events\Event; use Bottledcode\DurablePhp\Events\GiveOwnership; use Bottledcode\DurablePhp\Events\RaiseEvent; use Bottledcode\DurablePhp\Events\RevokeRole; @@ -36,6 +37,7 @@ use Bottledcode\DurablePhp\Events\TaskCompleted; use Bottledcode\DurablePhp\Events\WithDelay; use Bottledcode\DurablePhp\Events\WithEntity; +use Bottledcode\DurablePhp\Events\WithFrom; use Bottledcode\DurablePhp\Events\WithOrchestration; use Bottledcode\DurablePhp\Exceptions\Unwind; use Bottledcode\DurablePhp\Glue\Provenance; @@ -59,6 +61,8 @@ class EntityContext implements EntityContextInterface { private static ?EntityContextInterface $current = null; + private readonly StateId $from; + public function __construct( private readonly EntityId $id, private readonly string $operation, @@ -72,6 +76,7 @@ public function __construct( private readonly Provenance $user, ) { self::$current = $this; + $this->from = StateId::fromEntityId($this->id); } public static function current(): static @@ -112,9 +117,11 @@ public function signalEntity( array $input = [], ?DateTimeImmutable $scheduledTime = null, ): void { - $event = WithEntity::forInstance( - StateId::fromEntityId($entityId), - RaiseEvent::forOperation($operation, $input), + $event = $this->addFrom( + WithEntity::forInstance( + StateId::fromEntityId($entityId), + RaiseEvent::forOperation($operation, $input), + ), ); if ($scheduledTime) { $event = WithDelay::forEvent($scheduledTime, $event); @@ -122,6 +129,11 @@ public function signalEntity( $this->eventDispatcher->fire($event); } + private function addFrom(Event $event): Event + { + return WithFrom::forEvent($this->from, $event); + } + public function getId(): EntityId { return $this->id; @@ -140,9 +152,11 @@ public function startNewOrchestration(string $orchestration, array $input = [], $instance = StateId::fromInstance(OrchestrationInstance($orchestration, $id)); $this->eventDispatcher->fire( - WithOrchestration::forInstance( - $instance, - StartExecution::asParent($input, []), + $this->addFrom( + WithOrchestration::forInstance( + $instance, + StartExecution::asParent($input, []), + ), ), ); } @@ -184,9 +198,11 @@ public function delayUntil( DateTimeInterface $until = new DateTimeImmutable(), ): void { $this->eventDispatcher->fire( - WithDelay::forEvent( - $until, - WithEntity::forInstance(StateId::fromEntityId($this->id), RaiseEvent::forOperation($operation, $args)), + $this->addFrom( + WithDelay::forEvent( + $until, + WithEntity::forInstance(StateId::fromEntityId($this->id), RaiseEvent::forOperation($operation, $args)), + ), ), ); } @@ -199,9 +215,11 @@ public function currentUserId(): string public function shareOwnership(string $withUser): void { $this->eventDispatcher->fire( - WithEntity::forInstance( - StateId::fromEntityId($this->id), - ShareOwnership::withUser($withUser), + $this->addFrom( + WithEntity::forInstance( + StateId::fromEntityId($this->id), + ShareOwnership::withUser($withUser), + ), ), ); } @@ -209,9 +227,11 @@ public function shareOwnership(string $withUser): void public function giveOwnership(string $withUser): void { $this->eventDispatcher->fire( - WithEntity::forInstance( - StateId::fromEntityId($this->id), - GiveOwnership::withUser($withUser), + $this->addFrom( + WithEntity::forInstance( + StateId::fromEntityId($this->id), + GiveOwnership::withUser($withUser), + ), ), ); } @@ -219,9 +239,11 @@ public function giveOwnership(string $withUser): void public function grantUser(string $withUser, Operation ...$operation): void { $this->eventDispatcher->fire( - WithEntity::forInstance( - StateId::fromEntityId($this->id), - ShareWithUser::For($withUser, ...$operation), + $this->addFrom( + WithEntity::forInstance( + StateId::fromEntityId($this->id), + ShareWithUser::For($withUser, ...$operation), + ), ), ); } @@ -229,9 +251,11 @@ public function grantUser(string $withUser, Operation ...$operation): void public function grantRole(string $withRole, Operation ...$operation): void { $this->eventDispatcher->fire( - WithEntity::forInstance( - StateId::fromEntityId($this->id), - ShareWithRole::For($withRole, ...$operation), + $this->addFrom( + WithEntity::forInstance( + StateId::fromEntityId($this->id), + ShareWithRole::For($withRole, ...$operation), + ), ), ); } @@ -239,9 +263,11 @@ public function grantRole(string $withRole, Operation ...$operation): void public function revokeUser(string $user): void { $this->eventDispatcher->fire( - WithEntity::forInstance( - StateId::fromEntityId($this->id), - RevokeUser::completely($user), + $this->addFrom( + WithEntity::forInstance( + StateId::fromEntityId($this->id), + RevokeUser::completely($user), + ), ), ); } @@ -249,9 +275,11 @@ public function revokeUser(string $user): void public function revokeRole(string $role): void { $this->eventDispatcher->fire( - WithEntity::forInstance( - StateId::fromEntityId($this->id), - RevokeRole::completely($role), + $this->addFrom( + WithEntity::forInstance( + StateId::fromEntityId($this->id), + RevokeRole::completely($role), + ), ), ); } diff --git a/src/Events/RevokeUser.php b/src/Events/RevokeUser.php index b3032f97..8780ed7f 100644 --- a/src/Events/RevokeUser.php +++ b/src/Events/RevokeUser.php @@ -41,7 +41,7 @@ public static function completely(string $userId): self return new self($userId, null); } - public function __toString() + public function __toString(): string { return sprintf('Revoke(user: %s)', $this->userId); } diff --git a/src/Events/ShareOwnership.php b/src/Events/ShareOwnership.php index 2255a4aa..f78fca67 100644 --- a/src/Events/ShareOwnership.php +++ b/src/Events/ShareOwnership.php @@ -24,8 +24,11 @@ namespace Bottledcode\DurablePhp\Events; +use Bottledcode\DurablePhp\Events\Shares\NeedsSource; +use Bottledcode\DurablePhp\Events\Shares\Operation; use Ramsey\Uuid\Uuid; +#[NeedsSource(Operation::Owner)] class ShareOwnership extends Event implements External { private function __construct(public string $userId) @@ -38,7 +41,7 @@ public static function withUser(string $userId): self return new self($userId); } - public function __toString() + public function __toString(): string { return sprintf('ShareOwnership(%s)', $this->userId); } diff --git a/src/Events/ShareWithRole.php b/src/Events/ShareWithRole.php index e6d7d8e4..6f2059c6 100644 --- a/src/Events/ShareWithRole.php +++ b/src/Events/ShareWithRole.php @@ -42,7 +42,7 @@ public static function For(string $role, Operation ...$allowedOperations): self return new self($role, $allowedOperations); } - public function __toString() + public function __toString(): string { return sprintf('Share(role: %s, %s)', $this->role, implode(', ', $this->allowedOperations)); } diff --git a/src/Events/ShareWithUser.php b/src/Events/ShareWithUser.php index 89a37e92..0c1e56e9 100644 --- a/src/Events/ShareWithUser.php +++ b/src/Events/ShareWithUser.php @@ -42,7 +42,7 @@ public static function For(string $userId, Operation ...$allowedOperations): sel return new self($userId, $allowedOperations); } - public function __toString() + public function __toString(): string { return sprintf('Share(user: %s, %s)', $this->userId, implode(', ', $this->allowedOperations)); } diff --git a/src/Events/WithFrom.php b/src/Events/WithFrom.php new file mode 100644 index 00000000..5998faca --- /dev/null +++ b/src/Events/WithFrom.php @@ -0,0 +1,28 @@ +eventId, $from, $innerEvent); + } + + public function __toString(): string + { + return sprintf('WithFrom(%s, %s)', $this->from, $this->innerEvent); + } + + public function getInnerEvent(): Event + { + return $this->innerEvent; + } +} From a68af9854bc56b8d9c348d0a39089b0fc77fc9bb Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sat, 2 Aug 2025 21:45:20 +0200 Subject: [PATCH 02/10] allow orchestrations to work Signed-off-by: Robert Landers --- src/Events/Event.php | 16 ++++-- src/Events/HasInnerEventInterface.php | 2 + src/Events/WithActivity.php | 2 +- src/Events/WithDelay.php | 6 +-- src/Events/WithEntity.php | 2 +- src/Events/WithFrom.php | 2 +- src/Events/WithOrchestration.php | 2 +- src/Events/WithPriority.php | 5 +- src/OrchestrationContext.php | 71 +++++++++++++++++---------- tests/Unit/EventDescriptionTest.php | 6 +-- 10 files changed, 74 insertions(+), 40 deletions(-) diff --git a/src/Events/Event.php b/src/Events/Event.php index 50a22d40..4846f17c 100644 --- a/src/Events/Event.php +++ b/src/Events/Event.php @@ -27,16 +27,26 @@ use Bottledcode\DurablePhp\MonotonicClock; use Crell\fp\Evolvable; use Crell\Serde\Attributes\ClassNameTypeMap; +use DateTimeImmutable; use Ramsey\Uuid\Uuid; +use Stringable; #[ClassNameTypeMap(key: 'eventType')] -abstract class Event implements \Stringable +abstract class Event implements Stringable { use Evolvable; - public \DateTimeImmutable $timestamp; + public DateTimeImmutable $timestamp; - public function __construct(public string $eventId) + public function __construct(public string $eventId { + set(string $value) { + if ($this instanceof HasInnerEventInterface && ($this->innerEvent ?? null)) { + $this->innerEvent->eventId = $value; + } + $this->eventId = $value; + } + get => $this->eventId; + }) { $this->eventId = $this->eventId ?: Uuid::uuid7(); $this->timestamp = MonotonicClock::current()->now(); diff --git a/src/Events/HasInnerEventInterface.php b/src/Events/HasInnerEventInterface.php index 3918bcfe..27806abb 100644 --- a/src/Events/HasInnerEventInterface.php +++ b/src/Events/HasInnerEventInterface.php @@ -26,5 +26,7 @@ interface HasInnerEventInterface { + public Event $innerEvent { get; } + public function getInnerEvent(): Event; } diff --git a/src/Events/WithActivity.php b/src/Events/WithActivity.php index c57b437f..b278bbf4 100644 --- a/src/Events/WithActivity.php +++ b/src/Events/WithActivity.php @@ -30,7 +30,7 @@ class WithActivity extends Event implements HasInnerEventInterface, StateTargetInterface { - public function __construct(string $eventId, public StateId $target, private readonly Event $innerEvent) + public function __construct(string $eventId, public StateId $target, public Event $innerEvent) { parent::__construct($eventId); } diff --git a/src/Events/WithDelay.php b/src/Events/WithDelay.php index 78763d6c..1392858e 100644 --- a/src/Events/WithDelay.php +++ b/src/Events/WithDelay.php @@ -24,16 +24,17 @@ namespace Bottledcode\DurablePhp\Events; +use DateTimeImmutable; use Ramsey\Uuid\Uuid; class WithDelay extends Event implements HasInnerEventInterface { - public function __construct(string $eventId, public \DateTimeImmutable $fireAt, public Event $innerEvent) + public function __construct(string $eventId, public DateTimeImmutable $fireAt, public Event $innerEvent) { parent::__construct($this->innerEvent ?: Uuid::uuid7()); } - public static function forEvent(\DateTimeImmutable $fireAt, Event $innerEvent): static + public static function forEvent(DateTimeImmutable $fireAt, Event $innerEvent): static { return new static( $innerEvent->eventId, @@ -44,7 +45,6 @@ public static function forEvent(\DateTimeImmutable $fireAt, Event $innerEvent): public function getInnerEvent(): Event { - $this->innerEvent->eventId = $this->eventId; return $this->innerEvent; } diff --git a/src/Events/WithEntity.php b/src/Events/WithEntity.php index b2b24bc9..d6eaec4b 100644 --- a/src/Events/WithEntity.php +++ b/src/Events/WithEntity.php @@ -28,7 +28,7 @@ class WithEntity extends Event implements HasInnerEventInterface, StateTargetInterface { - public function __construct(string $eventId, public StateId $target, private readonly Event $innerEvent) + public function __construct(string $eventId, public StateId $target, public Event $innerEvent) { parent::__construct($eventId); } diff --git a/src/Events/WithFrom.php b/src/Events/WithFrom.php index 5998faca..4a41b607 100644 --- a/src/Events/WithFrom.php +++ b/src/Events/WithFrom.php @@ -6,7 +6,7 @@ class WithFrom extends Event implements HasInnerEventInterface { - public function __construct(string $eventId, public StateId $from, private readonly Event $innerEvent) + public function __construct(string $eventId, public StateId $from, public readonly Event $innerEvent) { parent::__construct($eventId); } diff --git a/src/Events/WithOrchestration.php b/src/Events/WithOrchestration.php index 002edf10..fbcca8ed 100644 --- a/src/Events/WithOrchestration.php +++ b/src/Events/WithOrchestration.php @@ -32,7 +32,7 @@ class WithOrchestration extends Event implements HasInnerEventInterface, StateTa public function __construct( string $eventId, public StateId $target, - private readonly Event $innerEvent, + public readonly Event $innerEvent, ) { parent::__construct($this->innerEvent->eventId ?: Uuid::uuid7()); } diff --git a/src/Events/WithPriority.php b/src/Events/WithPriority.php index 3afe8055..0ec2b6c8 100644 --- a/src/Events/WithPriority.php +++ b/src/Events/WithPriority.php @@ -28,7 +28,7 @@ class WithPriority extends Event implements HasInnerEventInterface { - private function __construct(public string $eventId, public int $priority, private Event $innerEvent) + private function __construct(public string $eventId, public int $priority, public Event $innerEvent) { parent::__construct($this->eventId ?? Uuid::uuid7()->toString()); } @@ -58,7 +58,8 @@ public function __toString(): string return sprintf('WithPriority(%d, %s)', $this->priority, $this->innerEvent); } - #[\Override] public function getInnerEvent(): Event + #[\Override] + public function getInnerEvent(): Event { return $this->innerEvent; } diff --git a/src/OrchestrationContext.php b/src/OrchestrationContext.php index a7759a49..9ed7596a 100644 --- a/src/OrchestrationContext.php +++ b/src/OrchestrationContext.php @@ -35,6 +35,7 @@ use Bottledcode\DurablePhp\Events\WithActivity; use Bottledcode\DurablePhp\Events\WithDelay; use Bottledcode\DurablePhp\Events\WithEntity; +use Bottledcode\DurablePhp\Events\WithFrom; use Bottledcode\DurablePhp\Events\WithLock; use Bottledcode\DurablePhp\Events\WithOrchestration; use Bottledcode\DurablePhp\Exceptions\Unwind; @@ -77,9 +78,11 @@ public function callActivity(string $name, ?string $returnType = null, ?RetryOpt return $this->createFuture( fn() => $this->taskController->fire( - AwaitResult::forEvent( - StateId::fromInstance($this->id), - WithActivity::forEvent($identity, ScheduleTask::forName($name, $args)), + $this->addFrom( + AwaitResult::forEvent( + StateId::fromInstance($this->id), + WithActivity::forEvent($identity, ScheduleTask::forName($name, $args)), + ), ), ), function (Event $event, string $eventIdentity) use ($identity): array { @@ -136,6 +139,14 @@ private function createFuture( return $future; } + private function addFrom(Event $event): Event + { + static $from = null; + $from ??= StateId::fromInstance($this->id); + + return WithFrom::forEvent($from, $event); + } + public function callActivityInline(Closure $activity): DurableFuture { $identity = $this->newGuid(); @@ -144,22 +155,26 @@ public function callActivityInline(Closure $activity): DurableFuture try { $result = $activity(); $this->taskController->fire( - WithOrchestration::forInstance( - StateId::fromInstance($this->id), - TaskCompleted::forId($identity->toString(), $result), + $this->addFrom( + WithOrchestration::forInstance( + StateId::fromInstance($this->id), + TaskCompleted::forId($identity->toString(), $result), + ), ), ); return [$identity]; } catch (Throwable $exception) { $this->taskController->fire( - WithOrchestration::forInstance( - StateId::fromInstance($this->id), - TaskFailed::forTask( - $identity->toString(), - $exception->getMessage(), - $exception->getTraceAsString(), - $exception::class, + $this->addFrom( + WithOrchestration::forInstance( + StateId::fromInstance($this->id), + TaskFailed::forTask( + $identity->toString(), + $exception->getMessage(), + $exception->getTraceAsString(), + $exception::class, + ), ), ), ); @@ -197,9 +212,11 @@ public function continueAsNew(array $args = []): never $this->history->restartAsNew($args); $this->taskController->fire( - WithOrchestration::forInstance( - StateId::fromInstance($this->id), - StartOrchestration::forInstance($this->id), + $this->addFrom( + WithOrchestration::forInstance( + StateId::fromInstance($this->id), + StartOrchestration::forInstance($this->id), + ), ), ); throw new Unwind(); @@ -216,9 +233,11 @@ public function createTimer(DateInterval|DateTimeImmutable $fireAt): DurableFutu return $this->createFuture( fn() => $this->taskController->fire( - WithOrchestration::forInstance( - StateId::fromInstance($this->id), - WithDelay::forEvent($fireAt, RaiseEvent::forTimer($identity)), + $this->addFrom( + WithOrchestration::forInstance( + StateId::fromInstance($this->id), + WithDelay::forEvent($fireAt, RaiseEvent::forTimer($identity)), + ), ), ), function (Event $event) use ($identity): array { @@ -385,7 +404,7 @@ public function lockEntity(EntityId ...$entityId): EntityLock ); $identity = $this->newGuid()->toString(); $future = $this->createFuture( - fn() => $this->taskController->fire(WithLock::onEntity($owner, $event, ...$entityId)), + fn() => $this->taskController->fire($this->addFrom(WithLock::onEntity($owner, $event, ...$entityId))), fn(Event $event, string $eventIdentity) => [$event, $identity === $eventIdentity], $identity, ); @@ -396,9 +415,11 @@ public function lockEntity(EntityId ...$entityId): EntityLock return new EntityLock(function () use ($owner): void { foreach ($this->history->locks as $lock) { $this->taskController->fire( - WithLock::onEntity( - $owner, - WithEntity::forInstance($lock, RaiseEvent::forUnlock($owner->id, null, null)), + $this->addFrom( + WithLock::onEntity( + $owner, + WithEntity::forInstance($lock, RaiseEvent::forUnlock($owner->id, null, null)), + ), ), ); } @@ -624,7 +645,7 @@ public function callEntity(EntityId $entityId, string $operation, array $args = $identity = $this->newGuid()->toString(); return $this->createFuture( - fn() => $this->taskController->fire($event), + fn() => $this->taskController->fire($this->addFrom($event)), fn(Event $event, string $eventIdentity) => [$event, $identity === $eventIdentity], $identity, ); @@ -645,7 +666,7 @@ public function signalEntity(EntityId $entityId, string $operation, array $args $event = WithLock::onEntity($id, $event); } - $this->taskController->fire($event); + $this->taskController->fire($this->addFrom($event)); } public function getCurrentUserId(): string diff --git a/tests/Unit/EventDescriptionTest.php b/tests/Unit/EventDescriptionTest.php index be1d2579..11a9575e 100644 --- a/tests/Unit/EventDescriptionTest.php +++ b/tests/Unit/EventDescriptionTest.php @@ -145,19 +145,19 @@ public function __toString(): string class MockWrapperEvent extends Event implements HasInnerEventInterface { - public function __construct(string $eventId = '', private Event $inner = new SimpleEvent()) + public function __construct(string $eventId = '', public Event $innerEvent = new SimpleEvent()) { parent::__construct($eventId ?: Uuid::uuid7()->toString()); } public function getInnerEvent(): Event { - return $this->inner; + return $this->innerEvent; } public function __toString(): string { - return 'MockWrapperEvent(' . $this->inner . ')'; + return 'MockWrapperEvent(' . $this->innerEvent . ')'; } } From a8379d754cd802255976ca44d1698ec7484ad075 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 3 Aug 2025 00:16:33 +0200 Subject: [PATCH 03/10] handle the go side Signed-off-by: Robert Landers --- cli/auth/createPermissions.go | 2 + cli/auth/resource.go | 51 +++++++++++++---- cli/auth/resourceManager.go | 4 +- cli/ids/id.go | 15 +++-- cli/lib/api.go | 22 ++++---- cli/lib/consumer.go | 72 +++++++++++++++++------- src/Events/EventDescription.php | 5 ++ src/Glue/glue.php | 10 ++++ src/State/Attributes/AllowCreateFrom.php | 28 +++++++++ 9 files changed, 161 insertions(+), 48 deletions(-) create mode 100644 src/State/Attributes/AllowCreateFrom.php diff --git a/cli/auth/createPermissions.go b/cli/auth/createPermissions.go index 176e6af6..ac5e99e2 100644 --- a/cli/auth/createPermissions.go +++ b/cli/auth/createPermissions.go @@ -25,6 +25,8 @@ type CreatePermissions struct { Limits Limits `json:"limits"` Users []UserId `json:"users"` Roles []Role `json:"roles"` + FromId []string `json:"from"` + FromType []string `json:"from-type"` TimeToLive uint64 `json:"ttl"` } diff --git a/cli/auth/resource.go b/cli/auth/resource.go index 899c80d9..b0aee5bf 100644 --- a/cli/auth/resource.go +++ b/cli/auth/resource.go @@ -46,14 +46,16 @@ import ( // - WantTo: checks if the current user can perform the given operation on the resource. // - Grant: grants a share to the resource. type Resource struct { - Owners map[UserId]struct{} `json:"owner"` - Shares []Share `json:"Shares"` - Mode Mode `json:"mode"` - mu sync.RWMutex - kv jetstream.KeyValue - id *ids.StateId - Expires time.Time - revision uint64 + Owners map[UserId]struct{} `json:"owner"` + Shares []Share `json:"Shares"` + Mode Mode `json:"mode"` + mu sync.RWMutex + kv jetstream.KeyValue + id *ids.StateId + AllowedFromTypes []string `json:"allowed_from_types"` + AllowedFromIds []*ids.StateId `json:"allowed_from_ids"` + Expires time.Time + revision uint64 } // NewResourcePermissions creates a new Resource with the specified owner and mode. If the owner is nil, the resource @@ -101,7 +103,7 @@ func (r *Resource) ShareOwnership(newUser UserId, currentUser *User, keepPermiss r.mu.Lock() defer r.mu.Unlock() - if currentUser != nil && !keepPermissions { + if !keepPermissions { delete(r.Owners, currentUser.UserId) } r.Owners[newUser] = struct{}{} @@ -123,13 +125,13 @@ func (r *Resource) ApplyPerms(id *ids.StateId, ctx context.Context, logger *zap. } // CanCreate Load permissions from cache if available, otherwise fetch from external source -func (r *Resource) CanCreate(id *ids.StateId, ctx context.Context, logger *zap.Logger) bool { +func (r *Resource) CanCreate(id *ids.StateId, from *ids.StateId, ctx context.Context, logger *zap.Logger) bool { perms, err := r.getOrCreatePermissions(id, ctx, logger) if err != nil { logger.Error("failed to create permissions", zap.Error(err)) return false } - return r.isUserPermitted(perms, ctx) + return r.isUserPermitted(perms, ctx) && r.AllowedFrom(from) } func (r *Resource) getOrCreatePermissions(id *ids.StateId, ctx context.Context, logger *zap.Logger) (CreatePermissions, error) { @@ -158,6 +160,10 @@ func (r *Resource) getOrCreatePermissions(id *ids.StateId, ctx context.Context, func (r *Resource) isUserPermitted(perms CreatePermissions, ctx context.Context) bool { r.Mode = perms.Mode + for _, p := range perms.FromId { + r.AllowedFromIds = append(r.AllowedFromIds, ids.ParseStateId(p)) + } + r.AllowedFromTypes = perms.FromType r.Expires = time.Now().Add(time.Duration(perms.TimeToLive) * time.Nanosecond) switch perms.Mode { case AnonymousMode: @@ -219,6 +225,29 @@ func (r *Resource) IsOwner(ctx context.Context) bool { return false } +func (r *Resource) AllowedFrom(from *ids.StateId) bool { + if len(r.AllowedFromIds) == 0 && len(r.AllowedFromTypes) == 0 { + return true + } + + for _, id := range r.AllowedFromIds { + if id.Kind == from.Kind && id.Id == from.Id { + return true + } + } + + for _, t := range r.AllowedFromTypes { + if ent, found := from.ToEntityId(); found && ent.Name == t { + return true + } + if orch, found := from.ToOrchestrationId(); found && orch.InstanceId == t { + return true + } + } + + return false +} + // WantTo determines if the user is able to perform the specified operation on the resource. // It accepts the operation to be performed and the context containing the user information. // If the resource mode is set to AnonymousMode, it allows any operation. diff --git a/cli/auth/resourceManager.go b/cli/auth/resourceManager.go index facc4f5c..20de469a 100644 --- a/cli/auth/resourceManager.go +++ b/cli/auth/resourceManager.go @@ -47,7 +47,7 @@ func GetResourceManager(ctx context.Context, stream jetstream.JetStream) *Resour // DiscoverResource is a method of the ResourceManager struct that is responsible for discovering a resource based on // the provided context, state ID, logger, and preventCreation flag -func (r *ResourceManager) DiscoverResource(ctx context.Context, id *ids.StateId, logger *zap.Logger, preventCreation bool) (*Resource, error) { +func (r *ResourceManager) DiscoverResource(ctx context.Context, id *ids.StateId, from *ids.StateId, logger *zap.Logger, preventCreation bool) (*Resource, error) { currentUser, _ := ctx.Value(appcontext.CurrentUserKey).(*User) data, err := r.kv.Get(ctx, id.ToSubject().String()) @@ -58,7 +58,7 @@ func (r *ResourceManager) DiscoverResource(ctx context.Context, id *ids.StateId, resource.kv = r.kv resource.id = id resource.revision = 0 - if resource.CanCreate(id, ctx, logger) { + if resource.CanCreate(id, from, ctx, logger) { err = resource.Update(ctx, logger) if err != nil { return nil, err diff --git a/cli/ids/id.go b/cli/ids/id.go index 4247c7dd..e6b1dba4 100644 --- a/cli/ids/id.go +++ b/cli/ids/id.go @@ -14,6 +14,11 @@ const ( Orchestration IdKind = "orchestration" ) +var ApiSource *StateId = &StateId{ + Id: "--api--", + Kind: "--api--", +} + // subjects type Subject struct { @@ -73,12 +78,12 @@ func (id StateId) String() string { return fmt.Sprintf("%s:%s", id.Kind, id.Id) } -func (id StateId) Name() string { +func (id StateId) Name() IdKind { if before, _, found := strings.Cut(id.Id, ":"); found { - return before + return IdKind(before) } - return string(Activity) + return Activity } func (id StateId) ToEntityId() (*EntityId, bool) { @@ -174,6 +179,6 @@ func (id *OrchestrationId) ToStateId() *StateId { } type StateId struct { - Id string - Kind IdKind + Id string `json:"id,omitempty"` + Kind IdKind `json:"kind,omitempty"` } diff --git a/cli/lib/api.go b/cli/lib/api.go index 440fa5b5..d8e7b663 100644 --- a/cli/lib/api.go +++ b/cli/lib/api.go @@ -262,7 +262,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po defer cancel() rm := auth.GetResourceManager(ctx, js) - res, err := rm.DiscoverResource(ctx, id, logger, true) + res, err := rm.DiscoverResource(ctx, id, ids.ApiSource, logger, true) if err != nil { logger.Error("DiscoverResource", zap.Error(err)) panic(err) @@ -312,7 +312,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po } if deleteAfter { - resource, err := rm.DiscoverResource(ctx, id, logger, false) + resource, err := rm.DiscoverResource(ctx, id, ids.ApiSource, logger, false) if err != nil { logger.Error("Unable to delete resource", zap.Error(err)) http.Error(writer, "Internal Server Error", http.StatusInternalServerError) @@ -354,7 +354,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po return } - r, err := rm.DiscoverResource(ctx, stateId, logger, true) + r, err := rm.DiscoverResource(ctx, stateId, ids.ApiSource, logger, true) if err != nil { logger.Error("Failed to discover resource", zap.Error(err)) http.Error(writer, "Not Found", http.StatusNotFound) @@ -430,7 +430,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po return } - r, err := rm.DiscoverResource(ctx, stateId, logger, true) + r, err := rm.DiscoverResource(ctx, stateId, ids.ApiSource, logger, true) if err != nil { logger.Error("Failed to discover resource", zap.Error(err)) http.Error(writer, "", http.StatusNotFound) @@ -485,7 +485,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po return } - r, err := rm.DiscoverResource(ctx, stateId, logger, true) + r, err := rm.DiscoverResource(ctx, stateId, ids.ApiSource, logger, true) if err != nil { logger.Error("Failed to discover resource", zap.Error(err)) http.Error(writer, "", http.StatusNotFound) @@ -571,7 +571,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po } logger.Debug("Delete entity", zap.String("id", id.String())) - rs, err := rm.DiscoverResource(ctx, id.ToStateId(), logger, true) + rs, err := rm.DiscoverResource(ctx, id.ToStateId(), ids.ApiSource, logger, true) if err != nil { logger.Error("Failed to discover resource", zap.Error(err)) http.Error(writer, "Not Found", http.StatusNotFound) @@ -682,7 +682,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po return } - r, err := rm.DiscoverResource(ctx, stateId, logger, true) + r, err := rm.DiscoverResource(ctx, stateId, ids.ApiSource, logger, true) if err != nil { logger.Error("Failed to discover resource", zap.Error(err)) http.Error(writer, "Not Found", http.StatusNotFound) @@ -758,7 +758,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po return } - r, err := rm.DiscoverResource(ctx, stateId, logger, true) + r, err := rm.DiscoverResource(ctx, stateId, ids.ApiSource, logger, true) if err != nil { logger.Error("Failed to discover resource", zap.Error(err)) http.Error(writer, "", http.StatusNotFound) @@ -813,7 +813,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po return } - r, err := rm.DiscoverResource(ctx, stateId, logger, true) + r, err := rm.DiscoverResource(ctx, stateId, ids.ApiSource, logger, true) if err != nil { logger.Error("Failed to discover resource", zap.Error(err)) http.Error(writer, "", http.StatusNotFound) @@ -878,7 +878,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po return } - rs, err := rm.DiscoverResource(ctx, id.ToStateId(), logger, true) + rs, err := rm.DiscoverResource(ctx, id.ToStateId(), ids.ApiSource, logger, true) if err != nil { logger.Error("Failed to discover a resource for deletion", zap.Error(err)) http.Error(writer, "Not Found", http.StatusNotFound) @@ -1032,7 +1032,7 @@ func authorize( logger.Info("Authenticating with user", zap.Any("user", user)) ctx = auth.DecorateContextWithUser(ctx, user) } - resource, err := rm.DiscoverResource(ctx, id, logger, preventCreation) + resource, err := rm.DiscoverResource(ctx, id, ids.ApiSource, logger, preventCreation) if err != nil { logger.Warn("User attempted to create new resource not authorized to create", zap.Any("id", id.String()), zap.Any("user", auth.GetUserFromContext(ctx)), zap.Error(err)) http.Error(writer, "Not Authorized", http.StatusForbidden) diff --git a/cli/lib/consumer.go b/cli/lib/consumer.go index 8adeddc2..49309f8e 100644 --- a/cli/lib/consumer.go +++ b/cli/lib/consumer.go @@ -125,31 +125,35 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j ctx = auth.DecorateContextWithUser(ctx, currentUser) } + // retrieve the source + sourceId := ids.ParseStateId(msg.Headers().Get(string(glue.HeaderEmittedBy))) + if config.Extensions.Authz.Enabled { // extract the source operations sourceOps := strings.Split(msg.Headers().Get(string(glue.HeaderSourceOps)), ",") - // retrieve the source - sourceId := ids.ParseStateId(msg.Headers().Get(string(glue.HeaderEmittedBy))) - if sourceR, err := rm.DiscoverResource(ctx, sourceId, logger, true); err != nil { - if sourceR == nil { - logger.Warn("User accessed missing object", zap.Any("operation", sourceOps), zap.String("from", sourceId.Id), zap.String("to", id.Id), zap.String("user", string(currentUser.UserId))) - msg.Ack() - return nil - } - - for _, op := range sourceOps { - if !sourceR.WantTo(auth.Operation(op), ctx) { - // user isn't allowed to do this, so warn - logger.Warn("User attempted to perform an unauthorized operation", zap.String("operation", op), zap.String("From", sourceId.Id), zap.String("To", id.Id), zap.String("User", string(currentUser.UserId))) + // it isn't clear why we need to check the source, so the following is commented out + /* + if sourceR, err := rm.DiscoverResource(ctx, sourceId, logger, true); err != nil { + if sourceR == nil { + logger.Warn("User accessed missing object", zap.Any("operation", sourceOps), zap.String("from", sourceId.Id), zap.String("to", id.Id), zap.String("user", string(currentUser.UserId))) msg.Ack() return nil } + + for _, op := range sourceOps { + if !sourceR.WantTo(auth.Operation(op), ctx) { + // user isn't allowed to do this, so warn + logger.Warn("User attempted to perform an unauthorized operation", zap.String("operation", op), zap.String("From", sourceId.Id), zap.String("To", id.Id), zap.String("User", string(currentUser.UserId))) + msg.Ack() + return nil + } + } } - } + */ // extract the target operations targetOps := strings.Split(msg.Headers().Get(string(glue.HeaderTargetOps)), ",") - shouldCreate := false + preventCreation := true for _, op := range targetOps { switch auth.Operation(op) { case auth.Signal: @@ -159,11 +163,11 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j case auth.Lock: fallthrough case auth.Output: - shouldCreate = true + preventCreation = false } } - resource, err := rm.DiscoverResource(ctx, id, logger, !shouldCreate) + resource, err := rm.DiscoverResource(ctx, id, sourceId, logger, preventCreation) if err != nil { logger.Warn("User attempted to perform an unauthorized operation", zap.String("operation", "create"), zap.String("From", sourceId.Id), zap.String("To", id.Id), zap.String("User", string(currentUser.UserId))) msg.Ack() @@ -185,6 +189,11 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j switch msg.Headers().Get(string(glue.HeaderEventType)) { case "RevokeRole": + if !resource.WantTo(auth.ShareMinus, ctx) { + logger.Warn("User attempted to perform an unauthorized operation", zap.String("operation", "revokeRole"), zap.String("From", sourceId.Id), zap.String("To", id.Id), zap.String("User", string(currentUser.UserId))) + msg.Ack() + return nil + } role := meta["role"].(string) err := resource.RevokeRole(auth.Role(role), ctx) @@ -198,6 +207,11 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j msg.Ack() return nil case "RevokeUser": + if !resource.WantTo(auth.ShareMinus, ctx) { + logger.Warn("User attempted to perform an unauthorized operation", zap.String("operation", "revokeRole"), zap.String("From", sourceId.Id), zap.String("To", id.Id), zap.String("User", string(currentUser.UserId))) + msg.Ack() + return nil + } user := meta["userId"].(string) err := resource.RevokeUser(auth.UserId(user), ctx) if err != nil { @@ -210,6 +224,11 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j msg.Ack() return nil case "ShareWithRole": + if !resource.WantTo(auth.SharePlus, ctx) { + logger.Warn("User attempted to perform an unauthorized operation", zap.String("operation", "revokeRole"), zap.String("From", sourceId.Id), zap.String("To", id.Id), zap.String("User", string(currentUser.UserId))) + msg.Ack() + return nil + } role := meta["role"].(auth.Role) operations := meta["allowedOperations"].([]auth.Operation) @@ -226,6 +245,11 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j msg.Ack() return nil case "ShareWithUser": + if !resource.WantTo(auth.SharePlus, ctx) { + logger.Warn("User attempted to perform an unauthorized operation", zap.String("operation", "revokeRole"), zap.String("From", sourceId.Id), zap.String("To", id.Id), zap.String("User", string(currentUser.UserId))) + msg.Ack() + return nil + } role := meta["userId"].(auth.UserId) operations := meta["allowedOperations"].([]auth.Operation) @@ -242,6 +266,11 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j msg.Ack() return nil case "ShareOwnership": + if !resource.WantTo(auth.Owner, ctx) { + logger.Warn("User attempted to perform an unauthorized operation", zap.String("operation", "revokeRole"), zap.String("From", sourceId.Id), zap.String("To", id.Id), zap.String("User", string(currentUser.UserId))) + msg.Ack() + return nil + } userId := meta["userId"].(auth.UserId) user := ctx.Value(appcontext.CurrentUserKey).(*auth.User) err := resource.ShareOwnership(userId, user, true) @@ -255,6 +284,11 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j msg.Ack() return nil case "GiveOwnership": + if !resource.WantTo(auth.Owner, ctx) { + logger.Warn("User attempted to perform an unauthorized operation", zap.String("operation", "revokeRole"), zap.String("From", sourceId.Id), zap.String("To", id.Id), zap.String("User", string(currentUser.UserId))) + msg.Ack() + return nil + } userId := meta["userId"].(auth.UserId) user := ctx.Value(appcontext.CurrentUserKey).(*auth.User) err := resource.ShareOwnership(userId, user, true) @@ -292,7 +326,7 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j env["STATE_ID"] = msg.Headers().Get(string(glue.HeaderStateId)) env["REMOTE_ADDR"] = msg.Headers().Get("Remote-Addr") - res, err := rm.DiscoverResource(ctx, id, logger, true) + res, err := rm.DiscoverResource(ctx, id, sourceId, logger, true) if err != nil { logger.Error("DiscoverResource", zap.Error(err)) panic(err) @@ -332,7 +366,7 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j } if deleteAfter { - resource, err := rm.DiscoverResource(ctx, id, logger, false) + resource, err := rm.DiscoverResource(ctx, id, sourceId, logger, false) if err != nil { return err } diff --git a/src/Events/EventDescription.php b/src/Events/EventDescription.php index 44ad60f0..3685439e 100644 --- a/src/Events/EventDescription.php +++ b/src/Events/EventDescription.php @@ -41,6 +41,8 @@ public ?StateId $destination; + public ?StateId $from; + public string $eventId; public int $priority; @@ -95,6 +97,9 @@ private function describe(Event $event): void if ($event instanceof External) { $this->meta = Serializer::serialize($event); } + if ($event instanceof WithFrom) { + $this->from = $event->from; + } $reflection = new ReflectionClass($event); foreach ($reflection->getAttributes(NeedsTarget::class) as $target) { diff --git a/src/Glue/glue.php b/src/Glue/glue.php index 1c8e94f9..c3930bbd 100644 --- a/src/Glue/glue.php +++ b/src/Glue/glue.php @@ -36,6 +36,7 @@ use Bottledcode\DurablePhp\State\Attributes\AllowCreateForAuth; use Bottledcode\DurablePhp\State\Attributes\AllowCreateForRole; use Bottledcode\DurablePhp\State\Attributes\AllowCreateForUser; +use Bottledcode\DurablePhp\State\Attributes\AllowCreateFrom; use Bottledcode\DurablePhp\State\Attributes\TimeToLive; use Bottledcode\DurablePhp\State\EntityHistory; use Bottledcode\DurablePhp\State\Ids\StateId; @@ -258,6 +259,8 @@ private function getPermissions(): void 'mode' => 'explicit', 'users' => [], 'roles' => [], + 'from' => [], + 'from-type' => [], 'limits' => [ 'user' => -1, 'role' => -1, @@ -323,6 +326,13 @@ private function getPermissions(): void /** @var TimeToLive $attribute */ $attribute = $attribute->newInstance(); $permissions['ttl'] = $attribute->timeToLive()->as(Unit::Nanoseconds); break; + case $attribute->getName() === AllowCreateFrom::class: + if ($attribute->type) { + $permissions['from-type'][] = $attribute->type; + } else { + $permissions['from'][] = $attribute->id; + } + break; } } } diff --git a/src/State/Attributes/AllowCreateFrom.php b/src/State/Attributes/AllowCreateFrom.php new file mode 100644 index 00000000..1eeefbe0 --- /dev/null +++ b/src/State/Attributes/AllowCreateFrom.php @@ -0,0 +1,28 @@ + $type + */ + public function __construct(public ?string $type = null, public EntityId|OrchestrationInstance|null $id = null) + { + if ($type === null && $id === null) { + throw new LogicException('At least one of type or id must be provided to AllowCreateFrom'); + } + if ($type !== null && $id !== null) { + throw new LogicException('Only one of type or id can be provided to AllowCreateFrom'); + } + } +} From fcd35566ff9b26426a7f33838256de44d19cdac1 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 3 Aug 2025 00:18:03 +0200 Subject: [PATCH 04/10] fix withDelay Signed-off-by: Robert Landers --- src/Events/WithDelay.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Events/WithDelay.php b/src/Events/WithDelay.php index 1392858e..7d5435f4 100644 --- a/src/Events/WithDelay.php +++ b/src/Events/WithDelay.php @@ -31,7 +31,7 @@ class WithDelay extends Event implements HasInnerEventInterface { public function __construct(string $eventId, public DateTimeImmutable $fireAt, public Event $innerEvent) { - parent::__construct($this->innerEvent ?: Uuid::uuid7()); + parent::__construct($this->innerEvent->eventId ?: Uuid::uuid7()); } public static function forEvent(DateTimeImmutable $fireAt, Event $innerEvent): static From 909326728eafa48c15cfee78624cf811b1efc6f9 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 3 Aug 2025 00:20:23 +0200 Subject: [PATCH 05/10] fix orchestration context Signed-off-by: Robert Landers --- src/OrchestrationContext.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/OrchestrationContext.php b/src/OrchestrationContext.php index 9ed7596a..ddacb0d7 100644 --- a/src/OrchestrationContext.php +++ b/src/OrchestrationContext.php @@ -71,6 +71,8 @@ final class OrchestrationContext implements OrchestrationContextInterface private int $randomKey = 0; + private StateId $from; + public function callActivity(string $name, ?string $returnType = null, ?RetryOptions $retryOptions = null, mixed ...$args): DurableFuture { $this->durableLogger->debug('Calling activity', ['name' => $name]); @@ -141,10 +143,7 @@ private function createFuture( private function addFrom(Event $event): Event { - static $from = null; - $from ??= StateId::fromInstance($this->id); - - return WithFrom::forEvent($from, $event); + return WithFrom::forEvent($this->from, $event); } public function callActivityInline(Closure $activity): DurableFuture @@ -583,6 +582,7 @@ public function __construct( private readonly Provenance $user, ) { $this->history->historicalTaskResults->setCurrentTime(MonotonicClock::current()->now()); + $this->from = StateId::fromInstance($this->id); } public function entityOp(EntityId|string $id, Closure $operation): mixed From a3b2a096a3635ddb3134068fdc70422224b1fd1e Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 3 Aug 2025 01:33:41 +0200 Subject: [PATCH 06/10] allow fine-grained access control in php Signed-off-by: Robert Landers --- cli/auth/resource.go | 2 +- cli/auth/resourceManager.go | 11 +- cli/glue/glue.go | 7 +- cli/ids/id.go | 5 + cli/lib/api.go | 2 +- cli/lib/consumer.go | 2 +- src/Contexts/AuthContext.php | 6 + .../AuthContext/SecurityException.php | 5 + src/Contexts/AuthContext/functions.php | 4 + src/Glue/glue.php | 3 + src/State/AbstractHistory.php | 109 ++++++++++++++++-- src/State/ActivityHistory.php | 16 +++ src/State/Attributes/AccessControl.php | 5 + src/State/Attributes/AllowAnyOperation.php | 18 +++ src/State/Attributes/AllowCreateAll.php | 6 +- src/State/Attributes/AllowCreateForAuth.php | 6 +- src/State/Attributes/AllowCreateForRole.php | 6 +- src/State/Attributes/AllowCreateForUser.php | 6 +- src/State/Attributes/AllowCreateFrom.php | 4 +- src/State/Attributes/DenyAnyOperation.php | 40 +++++++ src/State/EntityHistory.php | 27 ++++- src/State/Ids/StateId.php | 22 ---- src/Task.php | 1 + tests/Unit/ActivityHistoryTest.php | 3 +- tests/Unit/EntityHistoryTest.php | 5 +- tests/Unit/LockIntegrationTest.php | 5 +- 26 files changed, 267 insertions(+), 59 deletions(-) create mode 100644 src/Contexts/AuthContext/SecurityException.php create mode 100644 src/State/Attributes/AccessControl.php create mode 100644 src/State/Attributes/AllowAnyOperation.php create mode 100644 src/State/Attributes/DenyAnyOperation.php diff --git a/cli/auth/resource.go b/cli/auth/resource.go index b0aee5bf..be0b7192 100644 --- a/cli/auth/resource.go +++ b/cli/auth/resource.go @@ -148,7 +148,7 @@ func (r *Resource) getOrCreatePermissions(id *ids.StateId, ctx context.Context, glu := glue.NewGlue(ctx.Value("bootstrap").(string), glue.GetPermissions, make([]any, 0), result.Name()) env := map[string]string{"STATE_ID": id.String()} - _, headers, _, _ := glu.Execute(ctx, make(http.Header), logger, env, nil, id) + _, headers, _, _ := glu.Execute(ctx, make(http.Header), logger, env, nil, id, ids.SystemSource) data := headers.Get("Permissions") if err = json.Unmarshal([]byte(data), &perms); err != nil { return perms, err diff --git a/cli/auth/resourceManager.go b/cli/auth/resourceManager.go index 20de469a..27aadab5 100644 --- a/cli/auth/resourceManager.go +++ b/cli/auth/resourceManager.go @@ -120,12 +120,19 @@ func (r *ResourceManager) ToAuthContext(ctx context.Context, resource *Resource) } } + fromIds := []string{} + for _, f := range resource.AllowedFromIds { + fromIds = append(fromIds, f.String()) + } + c := map[string]interface{}{ "contextId": map[string]string{ "id": resource.id.String(), }, - "owners": owners, - "shares": shares, + "owners": owners, + "shares": shares, + "fromTypes": resource.AllowedFromTypes, + "fromIds": fromIds, } return json.Marshal(c) diff --git a/cli/glue/glue.go b/cli/glue/glue.go index 42bfefc1..f3f8e317 100644 --- a/cli/glue/glue.go +++ b/cli/glue/glue.go @@ -73,7 +73,7 @@ func NewGlue(bootstrap string, function Method, input []any, payload string) *Gl } } -func FromApiRequest(ctx context.Context, r *http.Request, function Method, logger *zap.Logger, stream jetstream.JetStream, id *ids.StateId, headers http.Header) ([]*nats.Msg, string, error, *http.Header, bool) { +func FromApiRequest(ctx context.Context, r *http.Request, function Method, logger *zap.Logger, stream jetstream.JetStream, id *ids.StateId, from *ids.StateId, headers http.Header) ([]*nats.Msg, string, error, *http.Header, bool) { temp, err := os.CreateTemp("", "reqbody") if err != nil { return nil, "", err, nil, false @@ -103,7 +103,7 @@ func FromApiRequest(ctx context.Context, r *http.Request, function Method, logge remoteAddr := strings.Split(r.RemoteAddr, ":")[0] env["REMOTE_ADDR"] = remoteAddr - msgs, responseHeaders, _, deleteAfter := glu.Execute(ctx, headers, logger, env, stream, id) + msgs, responseHeaders, _, deleteAfter := glu.Execute(ctx, headers, logger, env, stream, id, from) for _, msg := range msgs { msg.Header.Add("Remote-Addr", remoteAddr) @@ -112,7 +112,7 @@ func FromApiRequest(ctx context.Context, r *http.Request, function Method, logge return msgs, temp.Name(), nil, &responseHeaders, deleteAfter } -func (g *Glue) Execute(ctx context.Context, headers http.Header, logger *zap.Logger, env map[string]string, stream jetstream.JetStream, id *ids.StateId) ([]*nats.Msg, http.Header, int, bool) { +func (g *Glue) Execute(ctx context.Context, headers http.Header, logger *zap.Logger, env map[string]string, stream jetstream.JetStream, id *ids.StateId, from *ids.StateId) ([]*nats.Msg, http.Header, int, bool) { var dir string var ok bool if dir, ok = GetLibraryDir("glue.php"); !ok { @@ -126,6 +126,7 @@ func (g *Glue) Execute(ctx context.Context, headers http.Header, logger *zap.Log headers.Add("DPHP_BOOTSTRAP", g.bootstrap) headers.Add("DPHP_FUNCTION", string(g.function)) headers.Add("DPHP_PAYLOAD", g.payload) + headers.Add("DPHP_SOURCE", from.String()) provenance := ctx.Value(appcontext.CurrentUserKey) if provenance != nil { diff --git a/cli/ids/id.go b/cli/ids/id.go index e6b1dba4..42bb7edc 100644 --- a/cli/ids/id.go +++ b/cli/ids/id.go @@ -19,6 +19,11 @@ var ApiSource *StateId = &StateId{ Kind: "--api--", } +var SystemSource *StateId = &StateId{ + Id: "--system--", + Kind: "--system--", +} + // subjects type Subject struct { diff --git a/cli/lib/api.go b/cli/lib/api.go index d8e7b663..90456c10 100644 --- a/cli/lib/api.go +++ b/cli/lib/api.go @@ -272,7 +272,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po headers.Add("DPHP_AUTH_CONTEXT", string(ac)) } - msgs, stateFile, err, responseHeaders, deleteAfter := glue.FromApiRequest(ctx, request, function, logger, js, id, headers) + msgs, stateFile, err, responseHeaders, deleteAfter := glue.FromApiRequest(ctx, request, function, logger, js, id, ids.ApiSource, headers) if err != nil { http.Error(writer, "Internal Server Error", http.StatusInternalServerError) logger.Error("Failed to glue", zap.Error(err)) diff --git a/cli/lib/consumer.go b/cli/lib/consumer.go index 49309f8e..8ae68c0f 100644 --- a/cli/lib/consumer.go +++ b/cli/lib/consumer.go @@ -336,7 +336,7 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j headers.Add("DPHP_AUTH_CONTEXT", string(ac)) } - msgs, headers, _, deleteAfter := glu.Execute(ctx, headers, logger, env, js, id) + msgs, headers, _, deleteAfter := glu.Execute(ctx, headers, logger, env, js, id, sourceId) // now update the stored state, if this fails due to optimistic concurrency, we immediately nak and fail err = update() diff --git a/src/Contexts/AuthContext.php b/src/Contexts/AuthContext.php index de8c9478..7d5fd4e9 100644 --- a/src/Contexts/AuthContext.php +++ b/src/Contexts/AuthContext.php @@ -25,6 +25,12 @@ #[SequenceField(arrayType: Share::class)] public array $shares; + #[SequenceField(arrayType: 'string')] + public array $fromTypes; + + #[SequenceField(arrayType: StateId::class)] + public array $fromIds; + public static function fromCurrentContext(): ?AuthContext { if (isset($_SERVER['HTTP_DPHP_AUTH_CONTEXT'])) { diff --git a/src/Contexts/AuthContext/SecurityException.php b/src/Contexts/AuthContext/SecurityException.php new file mode 100644 index 00000000..bfaaeea6 --- /dev/null +++ b/src/Contexts/AuthContext/SecurityException.php @@ -0,0 +1,5 @@ +getMethod('fromArgs')->invoke(null, subject: $subject, allowed: $allowed); } + +define('ApiSource', StateId::fromString('--api--:--api--')); +define('SystemSource', StateId::fromString('--system--:--system--')); diff --git a/src/Glue/glue.php b/src/Glue/glue.php index c3930bbd..0ebb1dd5 100644 --- a/src/Glue/glue.php +++ b/src/Glue/glue.php @@ -70,6 +70,8 @@ class Glue public StateId $target; + public StateId $source; + public $payloadHandle; public array $payload = []; @@ -102,6 +104,7 @@ public function __construct(private DurableLogger $logger) ); $this->provenance = null; } + $this->source = StateId::fromString($_SERVER['HTTP_DPHP_SOURCE']); if (! file_exists($_SERVER['HTTP_DPHP_PAYLOAD'])) { throw new LogicException('Unable to load payload'); diff --git a/src/State/AbstractHistory.php b/src/State/AbstractHistory.php index fa492ba9..b6df3f28 100644 --- a/src/State/AbstractHistory.php +++ b/src/State/AbstractHistory.php @@ -35,13 +35,27 @@ use Bottledcode\DurablePhp\Events\StartOrchestration; use Bottledcode\DurablePhp\Events\TaskCompleted; use Bottledcode\DurablePhp\Events\TaskFailed; +use Bottledcode\DurablePhp\Glue\Provenance; +use Bottledcode\DurablePhp\State\Attributes\AllowAnyOperation; +use Bottledcode\DurablePhp\State\Attributes\AllowCreateAll; +use Bottledcode\DurablePhp\State\Attributes\AllowCreateForAuth; +use Bottledcode\DurablePhp\State\Attributes\AllowCreateForRole; +use Bottledcode\DurablePhp\State\Attributes\AllowCreateForUser; +use Bottledcode\DurablePhp\State\Attributes\AllowCreateFrom; +use Bottledcode\DurablePhp\State\Attributes\DenyAnyOperation; use Bottledcode\DurablePhp\State\Ids\StateId; use Crell\Serde\Attributes\Field; +use Generator; use Psr\Container\ContainerInterface; +use ReflectionAttribute; abstract class AbstractHistory implements ApplyStateInterface, StateInterface { - public Status|null $status = null; + public ?Status $status = null; + + #[Field(exclude: true)] + public StateId $from; + #[Field(exclude: true)] protected ContainerInterface $container; @@ -50,7 +64,7 @@ public function setContainer(ContainerInterface $container): void $this->container = $container; } - public function applyAwaitResult(AwaitResult $event, Event $original): \Generator + public function applyAwaitResult(AwaitResult $event, Event $original): Generator { yield null; } @@ -60,37 +74,37 @@ public function getStatus(): Status return $this->status; } - public function applyExecutionTerminated(ExecutionTerminated $event, Event $original): \Generator + public function applyExecutionTerminated(ExecutionTerminated $event, Event $original): Generator { yield null; } - public function applyRaiseEvent(RaiseEvent $event, Event $original): \Generator + public function applyRaiseEvent(RaiseEvent $event, Event $original): Generator { yield null; } - public function applyScheduleTask(ScheduleTask $event, Event $original): \Generator + public function applyScheduleTask(ScheduleTask $event, Event $original): Generator { yield null; } - public function applyStartExecution(StartExecution $event, Event $original): \Generator + public function applyStartExecution(StartExecution $event, Event $original): Generator { yield null; } - public function applyStartOrchestration(StartOrchestration $event, Event $original): \Generator + public function applyStartOrchestration(StartOrchestration $event, Event $original): Generator { yield null; } - public function applyTaskCompleted(TaskCompleted $event, Event $original): \Generator + public function applyTaskCompleted(TaskCompleted $event, Event $original): Generator { yield null; } - public function applyTaskFailed(TaskFailed $event, Event $original): \Generator + public function applyTaskFailed(TaskFailed $event, Event $original): Generator { yield null; } @@ -112,7 +126,6 @@ protected function isRunning(): bool } /** - * @param Event $event * @return array */ protected function getReplyTo(Event $event): array @@ -124,6 +137,82 @@ protected function getReplyTo(Event $event): array } $event = $event->getInnerEvent(); } + return $reply; } + + protected function checkAccessControl(?Provenance $user, StateId $from, ReflectionAttribute ...$accessControls): bool + { + if (empty($accessControls)) { + // delegate to runtime + return true; + } + + foreach ($accessControls as $attr) { + $accessControl = $attr->newInstance(); + if ($accessControl instanceof AllowCreateAll) { + return true; + } + if ($accessControl instanceof AllowCreateForAuth) { + if ($user !== null && $user->userId !== '') { + return true; + } + } + if ($accessControl instanceof AllowCreateForRole) { + if (array_any($user->roles, fn($role) => $role === $accessControl->role)) { + return true; + } + } + if ($accessControl instanceof AllowCreateForUser) { + if ($user !== null && $user->userId === $accessControl->user) { + return true; + } + } + if ($accessControl instanceof AllowCreateFrom) { + if ($accessControl->id !== null) { + if (($from->isEntityId() ? $from->toEntityId() : $from->toOrchestrationInstance()) === $accessControl->id) { + return true; + } + } + if ($accessControl->type !== null) { + if ($from->isEntityId() && $from->toEntityId()->name === $accessControl->type) { + return true; + } + if ($from->isOrchestrationId() && $from->toOrchestrationInstance()->instanceId === $accessControl->type) { + return true; + } + } + } + if ($accessControl instanceof AllowAnyOperation) { + if ($accessControl->fromUser && $user->userId === $accessControl->fromUser) { + return true; + } + if ($accessControl->fromRole && array_any($user->roles, fn($role) => $role === $accessControl->fromRole)) { + return true; + } + if ($accessControl->fromId && ($from->isEntityId() ? $from->toEntityId() : $from->toOrchestrationInstance()) === $accessControl->fromId) { + return true; + } + if (($accessControl->fromType) && (($from->isEntityId() && $from->toEntityId()->name === $accessControl->fromType) || ($from->isOrchestrationId() && $from->toOrchestrationInstance()->instanceId === $accessControl->fromType))) { + return true; + } + } + if ($accessControl instanceof DenyAnyOperation) { + if ($accessControl->fromUser && $user->userId === $accessControl->fromUser) { + return false; + } + if ($accessControl->fromRole && array_any($user->roles, fn($role) => $role === $accessControl->fromRole)) { + return false; + } + if ($accessControl->fromId && ($from->isEntityId() ? $from->toEntityId() : $from->toOrchestrationInstance()) === $accessControl->fromId) { + return false; + } + if (($accessControl->fromType) && (($from->isEntityId() && $from->toEntityId()->name === $accessControl->fromType) || ($from->isOrchestrationId() && $from->toOrchestrationInstance()->instanceId === $accessControl->fromType))) { + return false; + } + } + } + + return false; + } } diff --git a/src/State/ActivityHistory.php b/src/State/ActivityHistory.php index b5ffa20a..6626c74a 100644 --- a/src/State/ActivityHistory.php +++ b/src/State/ActivityHistory.php @@ -24,6 +24,7 @@ namespace Bottledcode\DurablePhp\State; +use Bottledcode\DurablePhp\Contexts\AuthContext\SecurityException; use Bottledcode\DurablePhp\DurableLogger; use Bottledcode\DurablePhp\Events\Event; use Bottledcode\DurablePhp\Events\ScheduleTask; @@ -35,11 +36,13 @@ use Bottledcode\DurablePhp\Glue\Provenance; use Bottledcode\DurablePhp\MonotonicClock; use Bottledcode\DurablePhp\SerializedArray; +use Bottledcode\DurablePhp\State\Attributes\AccessControl; use Bottledcode\DurablePhp\State\Ids\StateId; use Crell\Serde\Attributes\Field; use Generator; use LogicException; use Override; +use ReflectionAttribute; use ReflectionClass; use ReflectionFunction; use RuntimeException; @@ -85,11 +88,24 @@ public function applyScheduleTask(ScheduleTask $event, Event $original): Generat try { if (is_callable($task)) { + $refl = new ReflectionFunction($task); + $attrs = $refl->getAttributes(AccessControl::class, ReflectionAttribute::IS_INSTANCEOF); + if (! $this->checkAccessControl($this->user, $this->from, ...$attrs)) { + throw new SecurityException(sprintf('Access denied to activity %s', $this->activityId)); + } $arguments = $this->fillParameters($event->input, new ReflectionFunction($task)); } elseif (! is_object($task)) { $task = $this->container->get($task); $reflection = new ReflectionClass($task); + $attrs = $reflection->getAttributes(AccessControl::class, ReflectionAttribute::IS_INSTANCEOF); + if (! $this->checkAccessControl($this->user, $this->from, ...$attrs)) { + throw new SecurityException(sprintf('Access denied to activity %s', $this->activityId)); + } $entrypoint = $this->locateEntrypoint($reflection) ?? throw new RuntimeException("Unable to locate entrypoint for {$event->name}"); + $attrs = $entrypoint->getAttributes(AccessControl::class, ReflectionAttribute::IS_INSTANCEOF); + if (! $this->checkAccessControl($this->user, $this->from, ...$attrs)) { + throw new SecurityException(sprintf('Access denied to activity %s', $this->activityId)); + } $arguments = $this->fillParameters($event->input, $entrypoint); } else { throw new LogicException('Activity must be callable or a class'); diff --git a/src/State/Attributes/AccessControl.php b/src/State/Attributes/AccessControl.php new file mode 100644 index 00000000..6db62642 --- /dev/null +++ b/src/State/Attributes/AccessControl.php @@ -0,0 +1,5 @@ +state)) { $reflector = new ReflectionClass($this->state); + $attributes = $reflector->getAttributes(AccessControl::class, ReflectionAttribute::IS_INSTANCEOF); + if (! $this->checkAccessControl($this->user, $this->from, ...$attributes)) { + throw new SecurityException('Access denied'); + } $properties = $reflector->getProperties(); foreach ($properties as $property) { $type = $property->getType(); @@ -214,12 +222,16 @@ private function execute(Event $original, string $operation, array $input): Gene if (str_contains($operation, '::')) { [$property, $operation] = explode('::', $operation); $property = str_replace('$', '', $property); - $result = match ($operation) { - 'get' => $this->state->{$property}, - 'set' => $this->state->{$property} = $input[0], + $operationReflection = $reflector->getProperty($property); + if (! $this->checkAccessControl($this->user, $this->from, ...$operationReflection->getAttributes(AccessControl::class, ReflectionAttribute::IS_INSTANCEOF))) { + throw new SecurityException('Access denied'); + } + $operationReflection = match ($operation) { + 'get' => $operationReflection->getHook(PropertyHookType::Get), + 'set' => $operationReflection->getHook(PropertyHookType::Set), default => throw new ReflectionException('Unknown operation'), }; - goto finalize; + goto done; } $operationReflection = $reflector->getMethod($operation); @@ -241,6 +253,11 @@ private function execute(Event $original, string $operation, array $input): Gene return; } done: + + if (! $this->checkAccessControl($this->user, $this->from, ...$operationReflection->getAttributes(AccessControl::class, ReflectionAttribute::IS_INSTANCEOF))) { + throw new SecurityException('Access denied'); + } + $input = $this->fillParameters($input, $operationReflection); try { $result = $operationReflection->getClosure($this->state); @@ -264,8 +281,6 @@ private function execute(Event $original, string $operation, array $input): Gene } } - finalize: - if ($replyTo) { foreach ($replyTo as $reply) { yield WithPriority::high( diff --git a/src/State/Ids/StateId.php b/src/State/Ids/StateId.php index d6ad8192..4cde9555 100644 --- a/src/State/Ids/StateId.php +++ b/src/State/Ids/StateId.php @@ -34,7 +34,6 @@ use Exception; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; -use RuntimeException; use Stringable; use Withinboredom\Record; @@ -154,27 +153,6 @@ public function isOrchestrationId(): bool return str_starts_with($this->id, 'orchestration:'); } - public function __invoke(EntityId|OrchestrationInstance|StateId|string|UuidInterface $id): self - { - if (is_string($id)) { - return new self($id); - } - if ($id instanceof self) { - return $id; - } - if ($id instanceof OrchestrationInstance) { - return self::fromInstance($id); - } - if ($id instanceof EntityId) { - return self::fromEntityId($id); - } - if ($id instanceof UuidInterface) { - return self::fromActivityId($id); - } - - throw new RuntimeException("Cannot convert {$id} to StateId"); - } - public function __toString(): string { return $this->id; diff --git a/src/Task.php b/src/Task.php index eaa32926..4cdf92ce 100644 --- a/src/Task.php +++ b/src/Task.php @@ -92,6 +92,7 @@ public function run(): void $this->container = $this->glue->bootstrap(); $state->setContainer($this->container); $state->setLogger($this->logger); + $state->from = $this->glue->source; if ($state->hasAppliedEvent($event->event)) { $this->emitError(200, 'event already applied', ['event' => $event]); diff --git a/tests/Unit/ActivityHistoryTest.php b/tests/Unit/ActivityHistoryTest.php index 29dc0df8..ff4fce01 100644 --- a/tests/Unit/ActivityHistoryTest.php +++ b/tests/Unit/ActivityHistoryTest.php @@ -22,7 +22,7 @@ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -//namespace Bottledcode\DurablePhp\Tests\Unit; +// namespace Bottledcode\DurablePhp\Tests\Unit; use Bottledcode\DurablePhp\Events\AwaitResult; use Bottledcode\DurablePhp\Events\ScheduleTask; @@ -65,6 +65,7 @@ function activity(bool $fail): void it('succeeds on no exception', function (): void { $history = new ActivityHistory(StateId::fromActivityId(Uuid::uuid7()), null, new Provenance('', [])); + $history->from = StateId::fromEntityId(EntityId('test', 'test')); $container = new Container([__NAMESPACE__ . '\activity' => activity(...)]); $history->setContainer($container); $event = AwaitResult::forEvent( diff --git a/tests/Unit/EntityHistoryTest.php b/tests/Unit/EntityHistoryTest.php index c27be01e..5f1e14fc 100644 --- a/tests/Unit/EntityHistoryTest.php +++ b/tests/Unit/EntityHistoryTest.php @@ -22,7 +22,7 @@ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -//namespace Bottledcode\DurablePhp\Tests\Unit; +// namespace Bottledcode\DurablePhp\Tests\Unit; use Bottledcode\DurablePhp\Events\AwaitResult; use Bottledcode\DurablePhp\Events\RaiseEvent; @@ -61,6 +61,7 @@ public function signal(): void } }, ); + $history->from = StateId::fromInstance(OrchestrationInstance('test', 'test')); processEvent( new RaiseEvent('id', '__signal', ['operation' => 'signal', 'input' => []]), @@ -84,6 +85,7 @@ public function signal(): void } }, ); + $history->from = StateId::fromInstance(OrchestrationInstance('test', 'test')); $owner = StateId::fromInstance(OrchestrationInstance('owner', 'owner')); $other = StateId::fromInstance(OrchestrationInstance('other', 'other')); @@ -136,6 +138,7 @@ public function signal(): void } }, ); + $history->from = StateId::fromInstance(OrchestrationInstance('test', 'test')); $owner = StateId::fromInstance(OrchestrationInstance('owner', 'owner')); $other = StateId::fromInstance(OrchestrationInstance('other', 'other')); diff --git a/tests/Unit/LockIntegrationTest.php b/tests/Unit/LockIntegrationTest.php index 47b43ac8..7b820c8d 100644 --- a/tests/Unit/LockIntegrationTest.php +++ b/tests/Unit/LockIntegrationTest.php @@ -22,12 +22,14 @@ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -//namespace Bottledcode\DurablePhp\Tests\Unit; +// namespace Bottledcode\DurablePhp\Tests\Unit; use Bottledcode\DurablePhp\OrchestrationContext; use Bottledcode\DurablePhp\State\EntityState; +use Bottledcode\DurablePhp\State\Ids\StateId; use function Bottledcode\DurablePhp\EntityId; +use function Bottledcode\DurablePhp\OrchestrationInstance; test('multilock example', function (): void { $instance = getOrchestration('test', function (OrchestrationContext $context) { @@ -49,6 +51,7 @@ public function test() } }, ); + $entity->from = StateId::fromInstance(OrchestrationInstance('test', 'test')); $result = processEvent($nextEvent, $instance->applyStartOrchestration(...)); $instance->resetState(); From 59172fa3dc25058239e62dfffa235be071f392dd Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 3 Aug 2025 01:35:18 +0200 Subject: [PATCH 07/10] add id kinds for api and system Signed-off-by: Robert Landers --- cli/ids/id.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli/ids/id.go b/cli/ids/id.go index 42bb7edc..4cea03e7 100644 --- a/cli/ids/id.go +++ b/cli/ids/id.go @@ -12,6 +12,8 @@ const ( Activity IdKind = "activity" Entity IdKind = "entity" Orchestration IdKind = "orchestration" + API IdKind = "--api--" + SYSTEM IdKind = "--system--" ) var ApiSource *StateId = &StateId{ From e2bf46f4b454ac9325b0a3678b332fa06bf2fcbb Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 3 Aug 2025 01:36:43 +0200 Subject: [PATCH 08/10] fix warning operations Signed-off-by: Robert Landers --- cli/lib/consumer.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cli/lib/consumer.go b/cli/lib/consumer.go index 8ae68c0f..c68dfb81 100644 --- a/cli/lib/consumer.go +++ b/cli/lib/consumer.go @@ -208,7 +208,7 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j return nil case "RevokeUser": if !resource.WantTo(auth.ShareMinus, ctx) { - logger.Warn("User attempted to perform an unauthorized operation", zap.String("operation", "revokeRole"), zap.String("From", sourceId.Id), zap.String("To", id.Id), zap.String("User", string(currentUser.UserId))) + logger.Warn("User attempted to perform an unauthorized operation", zap.String("operation", "revokeUser"), zap.String("From", sourceId.Id), zap.String("To", id.Id), zap.String("User", string(currentUser.UserId))) msg.Ack() return nil } @@ -225,7 +225,7 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j return nil case "ShareWithRole": if !resource.WantTo(auth.SharePlus, ctx) { - logger.Warn("User attempted to perform an unauthorized operation", zap.String("operation", "revokeRole"), zap.String("From", sourceId.Id), zap.String("To", id.Id), zap.String("User", string(currentUser.UserId))) + logger.Warn("User attempted to perform an unauthorized operation", zap.String("operation", "shareWithRole"), zap.String("From", sourceId.Id), zap.String("To", id.Id), zap.String("User", string(currentUser.UserId))) msg.Ack() return nil } @@ -246,7 +246,7 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j return nil case "ShareWithUser": if !resource.WantTo(auth.SharePlus, ctx) { - logger.Warn("User attempted to perform an unauthorized operation", zap.String("operation", "revokeRole"), zap.String("From", sourceId.Id), zap.String("To", id.Id), zap.String("User", string(currentUser.UserId))) + logger.Warn("User attempted to perform an unauthorized operation", zap.String("operation", "shareWithUser"), zap.String("From", sourceId.Id), zap.String("To", id.Id), zap.String("User", string(currentUser.UserId))) msg.Ack() return nil } @@ -267,7 +267,7 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j return nil case "ShareOwnership": if !resource.WantTo(auth.Owner, ctx) { - logger.Warn("User attempted to perform an unauthorized operation", zap.String("operation", "revokeRole"), zap.String("From", sourceId.Id), zap.String("To", id.Id), zap.String("User", string(currentUser.UserId))) + logger.Warn("User attempted to perform an unauthorized operation", zap.String("operation", "shareOwnership"), zap.String("From", sourceId.Id), zap.String("To", id.Id), zap.String("User", string(currentUser.UserId))) msg.Ack() return nil } @@ -285,7 +285,7 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j return nil case "GiveOwnership": if !resource.WantTo(auth.Owner, ctx) { - logger.Warn("User attempted to perform an unauthorized operation", zap.String("operation", "revokeRole"), zap.String("From", sourceId.Id), zap.String("To", id.Id), zap.String("User", string(currentUser.UserId))) + logger.Warn("User attempted to perform an unauthorized operation", zap.String("operation", "giveOwnership"), zap.String("From", sourceId.Id), zap.String("To", id.Id), zap.String("User", string(currentUser.UserId))) msg.Ack() return nil } From 5b811d504043b558157978ca514ffd3df076435c Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 3 Aug 2025 01:38:13 +0200 Subject: [PATCH 09/10] fix attribute Signed-off-by: Robert Landers --- src/Glue/glue.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Glue/glue.php b/src/Glue/glue.php index 0ebb1dd5..99bf8c71 100644 --- a/src/Glue/glue.php +++ b/src/Glue/glue.php @@ -330,6 +330,7 @@ private function getPermissions(): void $permissions['ttl'] = $attribute->timeToLive()->as(Unit::Nanoseconds); break; case $attribute->getName() === AllowCreateFrom::class: + /** @var AllowCreateFrom $attribute */ $attribute = $attribute->newInstance(); if ($attribute->type) { $permissions['from-type'][] = $attribute->type; } else { From 90d1d93ddd9b21ff9ce5042d6d8a9fda5b9b4820 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 3 Aug 2025 01:55:42 +0200 Subject: [PATCH 10/10] add tests Signed-off-by: Robert Landers --- src/State/AbstractHistory.php | 36 +-- tests/Unit/AbstractHistoryTest.php | 416 +++++++++++++++++++++++++++++ 2 files changed, 436 insertions(+), 16 deletions(-) create mode 100644 tests/Unit/AbstractHistoryTest.php diff --git a/src/State/AbstractHistory.php b/src/State/AbstractHistory.php index b6df3f28..4b2c3190 100644 --- a/src/State/AbstractHistory.php +++ b/src/State/AbstractHistory.php @@ -148,8 +148,26 @@ protected function checkAccessControl(?Provenance $user, StateId $from, Reflecti return true; } - foreach ($accessControls as $attr) { - $accessControl = $attr->newInstance(); + $controls = array_map(fn(ReflectionAttribute $attr) => $attr->newInstance(), $accessControls); + // put deny before allow + usort($controls, fn($left, $right) => get_class($right) <=> get_class($left)); + + foreach ($controls as $accessControl) { + if ($accessControl instanceof DenyAnyOperation) { + if ($accessControl->fromUser && $user->userId === $accessControl->fromUser) { + return false; + } + if ($accessControl->fromRole && array_any($user->roles, fn($role) => $role === $accessControl->fromRole)) { + return false; + } + if ($accessControl->fromId && ($from->isEntityId() ? $from->toEntityId() : $from->toOrchestrationInstance()) === $accessControl->fromId) { + return false; + } + if (($accessControl->fromType) && (($from->isEntityId() && $from->toEntityId()->name === $accessControl->fromType) || ($from->isOrchestrationId() && $from->toOrchestrationInstance()->instanceId === $accessControl->fromType))) { + return false; + } + } + if ($accessControl instanceof AllowCreateAll) { return true; } @@ -197,20 +215,6 @@ protected function checkAccessControl(?Provenance $user, StateId $from, Reflecti return true; } } - if ($accessControl instanceof DenyAnyOperation) { - if ($accessControl->fromUser && $user->userId === $accessControl->fromUser) { - return false; - } - if ($accessControl->fromRole && array_any($user->roles, fn($role) => $role === $accessControl->fromRole)) { - return false; - } - if ($accessControl->fromId && ($from->isEntityId() ? $from->toEntityId() : $from->toOrchestrationInstance()) === $accessControl->fromId) { - return false; - } - if (($accessControl->fromType) && (($from->isEntityId() && $from->toEntityId()->name === $accessControl->fromType) || ($from->isOrchestrationId() && $from->toOrchestrationInstance()->instanceId === $accessControl->fromType))) { - return false; - } - } } return false; diff --git a/tests/Unit/AbstractHistoryTest.php b/tests/Unit/AbstractHistoryTest.php new file mode 100644 index 00000000..0e3065e7 --- /dev/null +++ b/tests/Unit/AbstractHistoryTest.php @@ -0,0 +1,416 @@ +id = $id; + } + + public function checkAccessControlPublic(?Provenance $user, StateId $from, ReflectionAttribute ...$accessControls): bool + { + return $this->checkAccessControl($user, $from, ...$accessControls); + } + + public function resetState(): void + { + // Not needed for testing checkAccessControl + } + + public function ackedEvent(Event $event): void + { + // Not needed for testing checkAccessControl + } + + public function setLogger(DurableLogger $logger): void + { + // Not needed for testing checkAccessControl + } + + public function hasAppliedEvent(Event $event): bool + { + // Not needed for testing checkAccessControl + return false; + } +} + +// Helper function to create a ReflectionAttribute for testing +function createAttribute(string $attributeClass, array $args = []): ReflectionAttribute +{ + $class = new class ($attributeClass, $args) extends ReflectionAttribute { + private string $attributeClass; + + private array $args; + + public function __construct(string $attributeClass, array $args = []) + { + $this->attributeClass = $attributeClass; + $this->args = $args; + } + + public function getName(): string + { + return $this->attributeClass; + } + + public function getArguments(): array + { + return $this->args; + } + + public function newInstance(): object + { + return new $this->attributeClass(...$this->args); + } + }; + + return $class; +} + +// Test with empty access controls +it('returns true with empty access controls', function (): void { + $from = StateId::fromEntityId(EntityId('test', 'test')); + $user = new Provenance('user1', ['role1']); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + + $result = $history->checkAccessControlPublic($user, $from); + + expect($result)->toBeTrue(); +}); + +// Test with AllowCreateAll +it('returns true with AllowCreateAll', function (): void { + $from = StateId::fromEntityId(EntityId('test', 'test')); + $user = new Provenance('user1', ['role1']); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr = createAttribute(AllowCreateAll::class); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeTrue(); +}); + +// Test with AllowCreateForAuth +it('returns true with AllowCreateForAuth when user is authenticated', function (): void { + $from = StateId::fromEntityId(EntityId('test', 'test')); + $user = new Provenance('user1', ['role1']); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr = createAttribute(AllowCreateForAuth::class); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeTrue(); +}); + +it('returns false with AllowCreateForAuth when user is not authenticated', function (): void { + $from = StateId::fromEntityId(EntityId('test', 'test')); + $user = new Provenance('', []); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr = createAttribute(AllowCreateForAuth::class); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeFalse(); +}); + +it('returns false with AllowCreateForAuth when user is null', function (): void { + $from = StateId::fromEntityId(EntityId('test', 'test')); + $user = null; + $history = new TestableAbstractHistory($from, new DurableLogger(), new Provenance('', [])); + $attr = createAttribute(AllowCreateForAuth::class); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeFalse(); +}); + +// Test with AllowCreateForRole +it('returns true with AllowCreateForRole when user has the role', function (): void { + $from = StateId::fromEntityId(EntityId('test', 'test')); + $user = new Provenance('user1', ['role1', 'role2']); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr = createAttribute(AllowCreateForRole::class, ['role' => 'role1']); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeTrue(); +}); + +it('returns false with AllowCreateForRole when user does not have the role', function (): void { + $from = StateId::fromEntityId(EntityId('test', 'test')); + $user = new Provenance('user1', ['role1', 'role2']); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr = createAttribute(AllowCreateForRole::class, ['role' => 'role3']); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeFalse(); +}); + +// Test with AllowCreateForUser +it('returns true with AllowCreateForUser when user ID matches', function (): void { + $from = StateId::fromEntityId(EntityId('test', 'test')); + $user = new Provenance('user1', ['role1']); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr = createAttribute(AllowCreateForUser::class, ['user' => 'user1']); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeTrue(); +}); + +it('returns false with AllowCreateForUser when user ID does not match', function (): void { + $from = StateId::fromEntityId(EntityId('test', 'test')); + $user = new Provenance('user1', ['role1']); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr = createAttribute(AllowCreateForUser::class, ['user' => 'user2']); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeFalse(); +}); + +it('returns false with AllowCreateForUser when user is null', function (): void { + $from = StateId::fromEntityId(EntityId('test', 'test')); + $user = null; + $history = new TestableAbstractHistory($from, new DurableLogger(), new Provenance('', [])); + $attr = createAttribute(AllowCreateForUser::class, ['user' => 'user1']); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeFalse(); +}); + +// Test with AllowCreateFrom +it('returns true with AllowCreateFrom when entity ID matches', function (): void { + $user = new Provenance('user1', ['role1']); + $entityId = EntityId('test', 'test'); + $from = StateId::fromEntityId($entityId); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr = createAttribute(AllowCreateFrom::class, ['id' => $entityId]); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeTrue(); +}); + +it('returns true with AllowCreateFrom when entity type matches', function (): void { + $user = new Provenance('user1', ['role1']); + $from = StateId::fromEntityId(EntityId('test', 'test')); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr = createAttribute(AllowCreateFrom::class, ['type' => 'test']); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeTrue(); +}); + +it('returns true with AllowCreateFrom when orchestration ID matches', function (): void { + $user = new Provenance('user1', ['role1']); + $instance = OrchestrationInstance('test', 'test'); + $from = StateId::fromInstance($instance); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr = createAttribute(AllowCreateFrom::class, ['id' => $instance]); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeTrue(); +}); + +it('returns true with AllowCreateFrom when orchestration instance ID matches', function (): void { + $user = new Provenance('user1', ['role1']); + $from = StateId::fromInstance(OrchestrationInstance('test', 'test')); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr = createAttribute(AllowCreateFrom::class, ['type' => 'test']); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeTrue(); +}); + +// Test with AllowAnyOperation +it('returns true with AllowAnyOperation when user ID matches', function (): void { + $user = new Provenance('user1', ['role1']); + $from = StateId::fromEntityId(EntityId('test', 'test')); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr = createAttribute(AllowAnyOperation::class, ['fromUser' => 'user1']); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeTrue(); +}); + +it('returns true with AllowAnyOperation when role matches', function (): void { + $user = new Provenance('user1', ['role1', 'role2']); + $from = StateId::fromEntityId(EntityId('test', 'test')); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr = createAttribute(AllowAnyOperation::class, ['fromRole' => 'role2']); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeTrue(); +}); + +it('returns true with AllowAnyOperation when entity ID matches', function (): void { + $user = new Provenance('user1', ['role1']); + $entityId = EntityId('test', 'test'); + $from = StateId::fromEntityId($entityId); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr = createAttribute(AllowAnyOperation::class, ['fromId' => $entityId]); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeTrue(); +}); + +it('returns true with AllowAnyOperation when entity type matches', function (): void { + $user = new Provenance('user1', ['role1']); + $from = StateId::fromEntityId(EntityId('test', 'test')); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr = createAttribute(AllowAnyOperation::class, ['fromType' => 'test']); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeTrue(); +}); + +it('returns false with AllowAnyOperation when no conditions match', function (): void { + $user = new Provenance('user1', ['role1']); + $from = StateId::fromEntityId(EntityId('test', 'test')); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr = createAttribute(AllowAnyOperation::class, [ + 'fromUser' => 'user2', + 'fromRole' => 'role2', + 'fromId' => EntityId('other', 'other'), + 'fromType' => 'other', + ]); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeFalse(); +}); + +// Test with DenyAnyOperation +it('returns false with DenyAnyOperation when user ID matches', function (): void { + $user = new Provenance('user1', ['role1']); + $from = StateId::fromEntityId(EntityId('test', 'test')); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr = createAttribute(DenyAnyOperation::class, ['fromUser' => 'user1']); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeFalse(); +}); + +it('returns false with DenyAnyOperation when role matches', function (): void { + $user = new Provenance('user1', ['role1', 'role2']); + $from = StateId::fromEntityId(EntityId('test', 'test')); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr = createAttribute(DenyAnyOperation::class, ['fromRole' => 'role2']); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeFalse(); +}); + +it('returns false with DenyAnyOperation when entity ID matches', function (): void { + $user = new Provenance('user1', ['role1']); + $entityId = EntityId('test', 'test'); + $from = StateId::fromEntityId($entityId); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr = createAttribute(DenyAnyOperation::class, ['fromId' => $entityId]); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeFalse(); +}); + +it('returns false with DenyAnyOperation when entity type matches', function (): void { + $user = new Provenance('user1', ['role1']); + $from = StateId::fromEntityId(EntityId('test', 'test')); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr = createAttribute(DenyAnyOperation::class, ['fromType' => 'test']); + + $result = $history->checkAccessControlPublic($user, $from, $attr); + + expect($result)->toBeFalse(); +}); + +// Test with multiple attributes +it('returns true if any allow attribute matches', function (): void { + $user = new Provenance('user1', ['role1']); + $from = StateId::fromEntityId(EntityId('test', 'test')); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr1 = createAttribute(AllowCreateForUser::class, ['user' => 'user2']); + $attr2 = createAttribute(AllowCreateForUser::class, ['user' => 'user1']); + + $result = $history->checkAccessControlPublic($user, $from, $attr1, $attr2); + + expect($result)->toBeTrue(); +}); + +it('returns false if no allow attribute matches', function (): void { + $user = new Provenance('user1', ['role1']); + $from = StateId::fromEntityId(EntityId('test', 'test')); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr1 = createAttribute(AllowCreateForUser::class, ['user' => 'user2']); + $attr2 = createAttribute(AllowCreateForUser::class, ['user' => 'user3']); + + $result = $history->checkAccessControlPublic($user, $from, $attr1, $attr2); + + expect($result)->toBeFalse(); +}); + +it('returns false if a deny attribute matches even if allow attributes match', function (): void { + $user = new Provenance('user1', ['role1']); + $from = StateId::fromEntityId(EntityId('test', 'test')); + $history = new TestableAbstractHistory($from, new DurableLogger(), $user); + $attr1 = createAttribute(AllowCreateAll::class); + $attr2 = createAttribute(DenyAnyOperation::class, ['fromUser' => 'user1']); + + $result = $history->checkAccessControlPublic($user, $from, $attr1, $attr2); + + expect($result)->toBeFalse(); +});