Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.56% covered (success)
97.56%
40 / 41
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
NodeAccess
97.56% covered (success)
97.56%
40 / 41
90.00% covered (success)
90.00%
9 / 10
30
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 defaults
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 permission
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
 allows
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 serves
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 reads
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 readable
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 registered
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 declaresRead
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
 live
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Security;
6
7use Celema\Container\Container;
8use Cosray\Bootstrap;
9use Cosray\Node\Factory;
10use Cosray\Node\Types;
11use Cosray\Node\Wrapper;
12use Cosray\User;
13
14/**
15 * Who may read and write which nodes. A node type maps the actions `read`,
16 * `create`, `change` and `delete` to permissions through `#[Permission]`;
17 * actions it leaves out keep the defaults. `read` covers published,
18 * undeleted nodes; any other state takes the type's `change` permission.
19 */
20final class NodeAccess
21{
22    public const string READ = 'read';
23    public const string CREATE = 'create';
24    public const string CHANGE = 'change';
25    public const string DELETE = 'delete';
26
27    /** @var array<string, class-string>|null */
28    private ?array $registered = null;
29
30    public function __construct(
31        private readonly Policy $policy,
32        private readonly Types $types,
33        private readonly Container $container,
34    ) {}
35
36    /** @return array{read: string, create: string, change: string, delete: string} */
37    public static function defaults(bool $routable): array
38    {
39        return [
40            self::READ => $routable ? 'read-pages' : 'read-nodes',
41            self::CREATE => 'edit-nodes',
42            self::CHANGE => 'edit-nodes',
43            self::DELETE => 'edit-nodes',
44        ];
45    }
46
47    /**
48     * A string `#[Permission]` applies to every action.
49     *
50     * @param class-string $class
51     */
52    public function permission(string $class, string $action): string
53    {
54        $declared = $this->types->get($class, 'permission');
55
56        if (is_string($declared) && $declared !== '') {
57            return $declared;
58        }
59
60        $permission = is_array($declared) ? $declared[$action] ?? null : null;
61
62        if (is_string($permission) && $permission !== '') {
63            return $permission;
64        }
65
66        return self::defaults((bool) $this->types->get($class, 'routable', false))[$action] ?? 'edit-nodes';
67    }
68
69    /** @param class-string $class */
70    public function allows(?User $user, string $action, string $class): bool
71    {
72        return $this->policy->permits($user, $this->permission($class, $action));
73    }
74
75    /**
76     * Whether the website serves the node at its path to this user. It serves
77     * any published node with a path, unless the type declares a read
78     * permission, which then applies as in the API, even when it names the
79     * default permission.
80     */
81    public function serves(?User $user, object $node): bool
82    {
83        $class = Wrapper::unwrap($node)::class;
84
85        return !$this->declaresRead($class) || $this->allows($user, self::READ, $class);
86    }
87
88    public function reads(?User $user, object $node): bool
89    {
90        $inner = Wrapper::unwrap($node);
91
92        return $this->allows($user, self::live($inner) ? self::READ : self::CHANGE, $inner::class);
93    }
94
95    /**
96     * Handles of the registered types whose live nodes the user reads, and
97     * of those whose nodes the user reads in any state.
98     *
99     * @return array{live: list<string>, any: list<string>}
100     */
101    public function readable(?User $user): array
102    {
103        $live = [];
104        $any = [];
105
106        foreach ($this->registered() as $handle => $class) {
107            if ($this->allows($user, self::CHANGE, $class)) {
108                $any[] = $handle;
109            } elseif ($this->allows($user, self::READ, $class)) {
110                $live[] = $handle;
111            }
112        }
113
114        return ['live' => $live, 'any' => $any];
115    }
116
117    /** @return array<string, class-string> registered node classes by type handle */
118    public function registered(): array
119    {
120        if ($this->registered !== null) {
121            return $this->registered;
122        }
123
124        $registered = [];
125        $tag = $this->container->tag(Bootstrap::NODE_TAG);
126
127        foreach ($tag->entries() as $handle) {
128            $class = $tag->entry($handle)->definition();
129
130            if (is_string($class) && class_exists($class)) {
131                $registered[$handle] = $class;
132            }
133        }
134
135        return $this->registered = $registered;
136    }
137
138    /** @param class-string $class */
139    private function declaresRead(string $class): bool
140    {
141        if (!$this->types->schemaOf($class)->declares('permission')) {
142            return false;
143        }
144
145        $declared = $this->types->get($class, 'permission');
146
147        return is_string($declared) && $declared !== '' || is_array($declared) && ($declared[self::READ] ?? '') !== '';
148    }
149
150    private static function live(object $node): bool
151    {
152        return (bool) Factory::meta($node, 'published') && Factory::meta($node, 'deleted') === null;
153    }
154}