Skip to content

Commit e86a091

Browse files
soyukaclaude
andcommitted
feat: add WAC (Web Access Control) support
Adds getAcl() and putAcl() to SolidClient for reading and writing ACL documents. Discovers .acl URL via Link header, parses/serializes Turtle ACL documents with EasyRdf. Uses ML\IRI\IRI for resolving relative ACL URLs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b1bdc9a commit e86a091

File tree

4 files changed

+358
-0
lines changed

4 files changed

+358
-0
lines changed

src/SolidClient.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111

1212
namespace Dunglas\PhpSolidClient;
1313

14+
use Dunglas\PhpSolidClient\Wac\AclDocument;
1415
use EasyRdf\Graph;
16+
use ML\IRI\IRI;
1517
use ML\JsonLD\JsonLD;
1618
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
1719
use Symfony\Contracts\HttpClient\HttpClientInterface;
@@ -208,6 +210,36 @@ private static function getParentContainerUrl(string $url): ?string
208210
return $parent;
209211
}
210212

213+
/**
214+
* Fetches and parses the ACL document for a resource.
215+
*
216+
* Discovers the .acl URL via the Link header (rel="acl") from a HEAD request.
217+
*/
218+
public function getAcl(string $resourceUrl, array $options = []): AclDocument
219+
{
220+
$metadata = $this->getResourceMetadata($resourceUrl, $options);
221+
if (null === $metadata->aclUrl) {
222+
throw new Exception(\sprintf('No ACL URL found for resource "%s"', $resourceUrl));
223+
}
224+
225+
$aclUrl = (string) (new IRI($resourceUrl))->resolve($metadata->aclUrl);
226+
$response = $this->get($aclUrl, array_merge($options, [
227+
'headers' => ['Accept' => 'text/turtle'],
228+
]));
229+
230+
return AclDocument::fromTurtle($response->getContent(), $aclUrl, $resourceUrl);
231+
}
232+
233+
/**
234+
* Writes an ACL document for a resource.
235+
*/
236+
public function putAcl(string $resourceUrl, AclDocument $acl, array $options = []): ResponseInterface
237+
{
238+
return $this->put($acl->aclUrl, $acl->toTurtle(), false, array_merge($options, [
239+
'headers' => ['Content-Type' => 'text/turtle'],
240+
]));
241+
}
242+
211243
public function request(string $method, string $url, array $options = []): ResponseInterface
212244
{
213245
if ($accessToken = $this->oidcClient?->getAccessToken()) {

src/Wac/AclDocument.php

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Solid Client PHP project.
5+
* (c) Kévin Dunglas <kevin@dunglas.fr>
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
declare(strict_types=1);
11+
12+
namespace Dunglas\PhpSolidClient\Wac;
13+
14+
final class AclDocument
15+
{
16+
private const ACL_NS = 'http://www.w3.org/ns/auth/acl#';
17+
private const FOAF_NS = 'http://xmlns.com/foaf/0.1/';
18+
19+
/**
20+
* @param list<Authorization> $authorizations
21+
*/
22+
public function __construct(
23+
public readonly string $aclUrl,
24+
public array $authorizations = [],
25+
) {
26+
}
27+
28+
/**
29+
* Serializes the ACL document to Turtle format.
30+
*/
31+
public function toTurtle(): string
32+
{
33+
$lines = [
34+
'@prefix acl: <'.self::ACL_NS.'>.',
35+
'@prefix foaf: <'.self::FOAF_NS.'>.',
36+
'',
37+
];
38+
39+
foreach ($this->authorizations as $i => $auth) {
40+
$lines[] = '<#auth'.$i.'>';
41+
$lines[] = ' a acl:Authorization;';
42+
43+
foreach ($auth->agents as $agent) {
44+
$lines[] = ' acl:agent <'.$agent.'>;';
45+
}
46+
foreach ($auth->agentClasses as $class) {
47+
$short = self::shortenUri($class);
48+
$lines[] = ' acl:agentClass '.$short.';';
49+
}
50+
foreach ($auth->modes as $mode) {
51+
$short = self::shortenUri($mode);
52+
$lines[] = ' acl:mode '.$short.';';
53+
}
54+
55+
$lines[] = ' acl:accessTo <'.$auth->accessTo.'>;';
56+
57+
if ($auth->isDefault) {
58+
$lines[] = ' acl:default <'.$auth->accessTo.'>;';
59+
}
60+
61+
// Replace last semicolon with period
62+
$lastIndex = \count($lines) - 1;
63+
$lines[$lastIndex] = rtrim($lines[$lastIndex], ';').'.';
64+
$lines[] = '';
65+
}
66+
67+
return implode("\n", $lines);
68+
}
69+
70+
/**
71+
* Parses a Turtle ACL document using EasyRdf.
72+
*/
73+
public static function fromTurtle(string $turtle, string $aclUrl, string $resourceUrl): self
74+
{
75+
\EasyRdf\RdfNamespace::set('acl', self::ACL_NS);
76+
\EasyRdf\RdfNamespace::set('foaf', self::FOAF_NS);
77+
78+
$graph = new \EasyRdf\Graph($aclUrl, $turtle, 'turtle');
79+
$authorizations = [];
80+
81+
foreach ($graph->allOfType('acl:Authorization') as $resource) {
82+
$agents = [];
83+
foreach ($resource->allResources('acl:agent') as $agent) {
84+
$agents[] = $agent->getUri();
85+
}
86+
87+
$agentClasses = [];
88+
foreach ($resource->allResources('acl:agentClass') as $class) {
89+
$agentClasses[] = $class->getUri();
90+
}
91+
92+
$modes = [];
93+
foreach ($resource->allResources('acl:mode') as $mode) {
94+
$modes[] = $mode->getUri();
95+
}
96+
97+
$accessTo = $resource->getResource('acl:accessTo')?->getUri() ?? $resourceUrl;
98+
$isDefault = null !== $resource->getResource('acl:default');
99+
100+
$authorizations[] = new Authorization($accessTo, $agents, $agentClasses, $modes, $isDefault);
101+
}
102+
103+
return new self($aclUrl, $authorizations);
104+
}
105+
106+
private static function shortenUri(string $uri): string
107+
{
108+
if (str_starts_with($uri, self::ACL_NS)) {
109+
return 'acl:'.substr($uri, \strlen(self::ACL_NS));
110+
}
111+
if (str_starts_with($uri, self::FOAF_NS)) {
112+
return 'foaf:'.substr($uri, \strlen(self::FOAF_NS));
113+
}
114+
115+
return '<'.$uri.'>';
116+
}
117+
}

src/Wac/Authorization.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Solid Client PHP project.
5+
* (c) Kévin Dunglas <kevin@dunglas.fr>
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
declare(strict_types=1);
11+
12+
namespace Dunglas\PhpSolidClient\Wac;
13+
14+
final class Authorization
15+
{
16+
public const MODE_READ = 'http://www.w3.org/ns/auth/acl#Read';
17+
public const MODE_WRITE = 'http://www.w3.org/ns/auth/acl#Write';
18+
public const MODE_APPEND = 'http://www.w3.org/ns/auth/acl#Append';
19+
public const MODE_CONTROL = 'http://www.w3.org/ns/auth/acl#Control';
20+
21+
public const AGENT_CLASS_PUBLIC = 'http://xmlns.com/foaf/0.1/Agent';
22+
public const AGENT_CLASS_AUTHENTICATED = 'http://www.w3.org/ns/auth/acl#AuthenticatedAgent';
23+
24+
/**
25+
* @param list<string> $agents WebID URIs
26+
* @param list<string> $agentClasses foaf:Agent (public) or acl:AuthenticatedAgent
27+
* @param list<string> $modes acl:Read, acl:Write, acl:Append, acl:Control
28+
*/
29+
public function __construct(
30+
public string $accessTo,
31+
public array $agents = [],
32+
public array $agentClasses = [],
33+
public array $modes = [],
34+
public bool $isDefault = false,
35+
) {
36+
}
37+
}

tests/WacTest.php

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Solid Client PHP project.
5+
* (c) Kévin Dunglas <kevin@dunglas.fr>
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
declare(strict_types=1);
11+
12+
namespace Dunglas\PhpSolidClient\Tests;
13+
14+
use Dunglas\PhpSolidClient\SolidClient;
15+
use Dunglas\PhpSolidClient\Wac\AclDocument;
16+
use Dunglas\PhpSolidClient\Wac\Authorization;
17+
use PHPUnit\Framework\TestCase;
18+
use Symfony\Component\HttpClient\MockHttpClient;
19+
use Symfony\Component\HttpClient\Response\MockResponse;
20+
21+
class WacTest extends TestCase
22+
{
23+
public function testAuthorizationConstants(): void
24+
{
25+
$this->assertSame('http://www.w3.org/ns/auth/acl#Read', Authorization::MODE_READ);
26+
$this->assertSame('http://www.w3.org/ns/auth/acl#Write', Authorization::MODE_WRITE);
27+
$this->assertSame('http://www.w3.org/ns/auth/acl#Append', Authorization::MODE_APPEND);
28+
$this->assertSame('http://www.w3.org/ns/auth/acl#Control', Authorization::MODE_CONTROL);
29+
}
30+
31+
public function testAclDocumentToTurtle(): void
32+
{
33+
$acl = new AclDocument('http://pod.example/resource.acl', [
34+
new Authorization(
35+
'http://pod.example/resource',
36+
['http://example.com/user#me'],
37+
[],
38+
[Authorization::MODE_READ, Authorization::MODE_WRITE, Authorization::MODE_CONTROL],
39+
),
40+
new Authorization(
41+
'http://pod.example/resource',
42+
[],
43+
[Authorization::AGENT_CLASS_PUBLIC],
44+
[Authorization::MODE_READ],
45+
),
46+
]);
47+
48+
$turtle = $acl->toTurtle();
49+
50+
$this->assertStringContainsString('@prefix acl:', $turtle);
51+
$this->assertStringContainsString('acl:agent <http://example.com/user#me>', $turtle);
52+
$this->assertStringContainsString('acl:agentClass foaf:Agent', $turtle);
53+
$this->assertStringContainsString('acl:mode acl:Read', $turtle);
54+
$this->assertStringContainsString('acl:mode acl:Write', $turtle);
55+
$this->assertStringContainsString('acl:mode acl:Control', $turtle);
56+
$this->assertStringContainsString('acl:accessTo <http://pod.example/resource>', $turtle);
57+
}
58+
59+
public function testAclDocumentFromTurtle(): void
60+
{
61+
$turtle = <<<'TURTLE'
62+
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
63+
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
64+
65+
<#owner>
66+
a acl:Authorization;
67+
acl:agent <http://example.com/user#me>;
68+
acl:mode acl:Read, acl:Write, acl:Control;
69+
acl:accessTo <http://pod.example/resource>;
70+
acl:default <http://pod.example/resource>.
71+
72+
<#public>
73+
a acl:Authorization;
74+
acl:agentClass foaf:Agent;
75+
acl:mode acl:Read;
76+
acl:accessTo <http://pod.example/resource>.
77+
TURTLE;
78+
79+
$acl = AclDocument::fromTurtle($turtle, 'http://pod.example/resource.acl', 'http://pod.example/resource');
80+
81+
$this->assertSame('http://pod.example/resource.acl', $acl->aclUrl);
82+
$this->assertCount(2, $acl->authorizations);
83+
84+
$owner = $acl->authorizations[0];
85+
$this->assertSame(['http://example.com/user#me'], $owner->agents);
86+
$this->assertContains(Authorization::MODE_READ, $owner->modes);
87+
$this->assertContains(Authorization::MODE_WRITE, $owner->modes);
88+
$this->assertContains(Authorization::MODE_CONTROL, $owner->modes);
89+
$this->assertTrue($owner->isDefault);
90+
91+
$public = $acl->authorizations[1];
92+
$this->assertSame([Authorization::AGENT_CLASS_PUBLIC], $public->agentClasses);
93+
$this->assertSame([Authorization::MODE_READ], $public->modes);
94+
$this->assertFalse($public->isDefault);
95+
}
96+
97+
public function testGetAcl(): void
98+
{
99+
$aclTurtle = <<<'TURTLE'
100+
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
101+
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
102+
103+
<#owner>
104+
a acl:Authorization;
105+
acl:agent <http://example.com/user#me>;
106+
acl:mode acl:Read, acl:Write, acl:Control;
107+
acl:accessTo <http://pod.example/data/file.ttl>.
108+
TURTLE;
109+
110+
$httpClient = new MockHttpClient([
111+
// HEAD response with Link: rel="acl"
112+
new MockResponse('', [
113+
'http_code' => 200,
114+
'response_headers' => [
115+
'Content-Type' => 'text/turtle',
116+
'Link' => '<http://pod.example/data/file.ttl.acl>; rel="acl"',
117+
],
118+
]),
119+
// GET ACL document
120+
new MockResponse($aclTurtle, [
121+
'http_code' => 200,
122+
'response_headers' => ['Content-Type' => 'text/turtle'],
123+
]),
124+
]);
125+
$client = new SolidClient($httpClient);
126+
127+
$acl = $client->getAcl('http://pod.example/data/file.ttl');
128+
129+
$this->assertSame('http://pod.example/data/file.ttl.acl', $acl->aclUrl);
130+
$this->assertCount(1, $acl->authorizations);
131+
$this->assertSame(['http://example.com/user#me'], $acl->authorizations[0]->agents);
132+
}
133+
134+
public function testPutAcl(): void
135+
{
136+
$response = new MockResponse('', ['http_code' => 201]);
137+
$httpClient = new MockHttpClient($response);
138+
$client = new SolidClient($httpClient);
139+
140+
$acl = new AclDocument('http://pod.example/data/file.ttl.acl', [
141+
new Authorization(
142+
'http://pod.example/data/file.ttl',
143+
['http://example.com/user#me'],
144+
[],
145+
[Authorization::MODE_READ, Authorization::MODE_WRITE],
146+
),
147+
]);
148+
149+
$client->putAcl('http://pod.example/data/file.ttl', $acl);
150+
151+
$this->assertSame('PUT', $response->getRequestMethod());
152+
$this->assertSame('http://pod.example/data/file.ttl.acl', $response->getRequestUrl());
153+
$this->assertStringContainsString('acl:agent', $response->getRequestOptions()['body']);
154+
}
155+
156+
public function testAclDocumentWithDefault(): void
157+
{
158+
$acl = new AclDocument('http://pod.example/container/.acl', [
159+
new Authorization(
160+
'http://pod.example/container/',
161+
['http://example.com/user#me'],
162+
[],
163+
[Authorization::MODE_READ, Authorization::MODE_WRITE],
164+
true,
165+
),
166+
]);
167+
168+
$turtle = $acl->toTurtle();
169+
170+
$this->assertStringContainsString('acl:default', $turtle);
171+
}
172+
}

0 commit comments

Comments
 (0)