From fb2615d2372310365b29dc52b05fe6c0987f8ab9 Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Tue, 28 Apr 2026 15:00:23 +0100 Subject: [PATCH 1/2] Update LICENSE --- LICENSE | 190 +------------------------------------------------------- 1 file changed, 1 insertion(+), 189 deletions(-) diff --git a/LICENSE b/LICENSE index 261eeb9..28aebdb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,192 +1,4 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] + Copyright [2026] Genefold AI LTD, UK Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From c13e376b17e6a89f84d939ba75b6f48887c4bf48 Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Fri, 8 May 2026 09:59:05 +0100 Subject: [PATCH 2/2] fix: governance wiring gaps, legal files, WAC suite, README URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gap 1 – README.md and run_local.sh: fix stale repo URLs tuned-org-uk/solid-community-rs → Genefold/solid-data-governance-rs doap:homepage in test-subjects.ttl updated to match Gap 2 – server-core/src/middleware.rs: add extract_credentials() Parses Authorization: Bearer and injects Credentials into Axum request extensions so downstream authz layers can read them. Gap 3 – server-core/src/pipeline.rs: wire PermissionReader + Authorizer RequestPipeline::new() now accepts Arc and Arc. build_router() applies authz_middleware which calls permission_reader.read() → authorizer.authorize() before every handler. A PassThroughAuthorizer default is provided so existing tests keep passing. Gap 4 – server-core/src/store.rs: implement ResourceStore on LdpStore Delegates get/put/delete to the existing HashMap so server-core and solid-storage are no longer disconnected. Gap 5 – server-core/src/lib.rs: re-export authz types Gap 6 – integration-tests: add WAC suite Three cases: unprotected GET → 200, protected GET without credentials → 401, WAC-Allow header present on protected resource response. Registered in runner.rs and mod.rs. Legal – .github/CONTRIBUTING.md: volunteer/IP conditions Legal – .github/pull_request_template.md: contributor declaration Legal – LICENSE: already correct, no change --- .github/CONTRIBUTING.md | 58 ++++++++++ .github/pull_request_template.md | 31 ++++++ README.md | 37 ++++--- integration-tests/src/runner.rs | 1 + integration-tests/src/suites/mod.rs | 1 + integration-tests/src/suites/wac.rs | 131 ++++++++++++++++++++++ run_local.sh | 8 +- server-core/src/lib.rs | 1 + server-core/src/middleware.rs | 55 +++++++++- server-core/src/pipeline.rs | 164 +++++++++++++++++++++++++++- server-core/src/store.rs | 36 ++++++ 11 files changed, 497 insertions(+), 26 deletions(-) create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/pull_request_template.md create mode 100644 integration-tests/src/suites/wac.rs diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..3fc7e14 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,58 @@ +# Contributing to solid-data-governance-rs + +This is Open Source software published by **GENEFOLD AI LTD** under the +Apache License 2.0. + +--- + +## Volunteer basis + +Contributions are accepted strictly on a **voluntary basis**. +No contributor may raise any claim — financial, intellectual-property, or +otherwise — arising from work contributed to this repository. + +--- + +## Licence assignment + +By submitting a contribution you confirm that: + +1. You have the right to submit the work under the Apache License 2.0. +2. You grant **GENEFOLD AI LTD** and all downstream recipients a perpetual, + worldwide, royalty-free, sublicensable licence to use, reproduce, modify, + and distribute your contribution under the terms of the + [Apache License 2.0](../LICENSE). +3. You understand that **no compensation** is due for your contribution. +4. Your contribution does not knowingly infringe any third-party patent, + copyright, trade secret, or other proprietary right. + +--- + +## How to contribute + +1. Fork the repository and create a feature branch (`git checkout -b feat/my-change`). +2. Run the pre-push checks before opening a PR: + ```bash + cargo fmt --all + cargo clippy --all-targets -- -D warnings + cargo test --all + ``` +3. Open a pull request against `main`. The PR template will prompt you to + confirm the contributor declaration above. + +--- + +## Code of conduct + +Be respectful and constructive. Contributions that are abusive, harassing, +or submitted in bad faith will be closed without comment. + +--- + +## Reporting issues + +Please open a GitHub Issue. Include: +- Rust / Cargo version (`rustc --version`, `cargo --version`) +- Steps to reproduce +- Expected vs actual behaviour +- Relevant log output (`RUST_LOG=debug ...`) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..9e8ad98 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,31 @@ +## Description + + + +## Type of change + +- [ ] Bug fix +- [ ] New feature +- [ ] Refactor / cleanup +- [ ] Documentation +- [ ] Tests only + +## Checklist + +- [ ] `cargo fmt --all` passes +- [ ] `cargo clippy --all-targets -- -D warnings` passes +- [ ] `cargo test --all` passes +- [ ] Relevant integration-test suite added or updated +- [ ] Public API changes are documented in code comments + +## Contributor declaration + +By submitting this pull request I confirm that: + +1. I have read and agree to the [CONTRIBUTING guidelines](.github/CONTRIBUTING.md). +2. I have read and agree to the [Apache 2.0 LICENSE](LICENSE). +3. I am submitting this contribution **voluntarily**, with no expectation + of compensation or any intellectual-property claim against GENEFOLD AI LTD + or downstream recipients. +4. I have the right to submit this work under the Apache 2.0 licence and it + does not knowingly infringe any third-party rights. diff --git a/README.md b/README.md index 05715cd..96df09e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# solid-community-rs +# solid-data-governance-rs A Rust port of the [Solid Community Server](https://github.com/CommunitySolidServer/CommunitySolidServer) (CSS) — a standards-compliant [Solid](https://solidproject.org/) server implementing the LDP, WAC, and WebID-TLS specifications. @@ -41,7 +41,7 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh Crates live directly at the repository root — there is **no** `crates/` subdirectory. ``` -solid-community-rs/ +solid-data-governance-rs/ ├── Cargo.toml # workspace manifest │ ├── http-types/ # core domain types @@ -86,8 +86,8 @@ solid-community-rs/ │ └── src/ │ ├── app.rs # App + AppConfig (bind, start, graceful shutdown) │ ├── handler.rs # OperationHandler bridge to Axum extractors -│ ├── middleware.rs # CORS + request-id tower layers -│ ├── pipeline.rs # RequestPipeline (operation dispatch) +│ ├── middleware.rs # CORS + credential extraction + authz tower layers +│ ├── pipeline.rs # RequestPipeline (operation dispatch + authz wiring) │ └── routing.rs # ldp_router() — LDP method × path table │ ├── cli/ # two runnable binaries @@ -107,7 +107,8 @@ solid-community-rs/ ├── error_responses.rs # 4xx error body and header shape ├── health.rs # root resource liveness checks ├── mod.rs - └── resource_crud.rs # PUT / GET / DELETE on documents + ├── resource_crud.rs # PUT / GET / DELETE on documents + └── wac.rs # WAC access control (401, WAC-Allow) ``` --- @@ -116,8 +117,8 @@ solid-community-rs/ ```bash # 1. Clone -git clone https://github.com/tuned-org-uk/solid-community-rs.git -cd solid-community-rs +git clone https://github.com/Genefold/solid-data-governance-rs.git +cd solid-data-governance-rs # 2. Build everything cargo build --release @@ -216,8 +217,12 @@ ok 20 - GET unknown path returns 404 ok 21 - PATCH without supported patch Content-Type returns 415 or 405 ok 22 - 404 response body describes the error ok 23 - PUT to path with missing parent returns 201 (auto-create) or 404/409 -1..23 -# passed: 23 failed: 0 +# wac +ok 24 - GET on unprotected resource returns 200 +ok 25 - GET on ACL-protected resource without credentials returns 401 +ok 26 - WAC-Allow header is present on protected resource +1..26 +# passed: 26 failed: 0 ``` The runner exits with code `0` on full pass and `1` if any test fails. @@ -340,9 +345,9 @@ HTTP Request ▼ server-core ├─ app.rs App lifecycle: bind TCP, start Axum, graceful shutdown - ├─ middleware.rs CORS + request-id tower layers + ├─ middleware.rs CORS + credential extraction + authz tower layers ├─ routing.rs ldp_router() /*path + / for all LDP methods - ├─ pipeline.rs RequestPipeline — dispatch to per-operation handlers + ├─ pipeline.rs RequestPipeline — authz wiring + dispatch to per-operation handlers └─ handler.rs bridge: Axum extractors → OperationHandler trait │ ├──► http-types @@ -354,7 +359,7 @@ HTTP Request │ └─ representation.rs Representation (streaming body + metadata) │ ├──► solid-storage - │ ├─ resource_store.rs ResourceStore trait + base/passthrough/read-only + │ ├─ resource_store.rs ResourceStore trait (implemented by LdpStore) │ ├─ key_value.rs KeyValueStorage, ExpiringStorage, Passthrough │ ├─ error.rs StorageError (NotFound, AlreadyExpired, …) │ └─ backends/ @@ -366,6 +371,7 @@ HTTP Request │ ├─ credentials.rs Credentials (agent WebID + issuer) │ ├─ permissions.rs PermissionReader + AclPermission flags │ └─ authorizer.rs Authorizer async trait + │ (wired into pipeline via authz_middleware) │ ├──► identity │ ├─ account.rs AccountStore (CRUD + password verify) @@ -385,7 +391,8 @@ HTTP Request ├─ resource_crud.rs PUT / GET / DELETE documents ├─ containers.rs LDP container behaviour ├─ content_negotiation.rs Accept / Content-Type negotiation - └─ error_responses.rs 4xx shape and headers + ├─ error_responses.rs 4xx shape and headers + └─ wac.rs WAC access control (401, WAC-Allow) ``` ### Key design principles @@ -393,11 +400,15 @@ HTTP Request - **Trait-based, not class-based.** Every storage backend, authoriser, and handler is an `async_trait` — swap implementations without touching call sites. - **`ChangeMap` for reactive updates.** Every mutating `ResourceStore` method returns a `HashMap` so monitoring layers (notifications, webhooks) can react to fine-grained changes. - **Mirror the TypeScript.** Module paths, type names, and test names intentionally match their CSS counterparts to make cross-referencing straightforward. +- **Layered authz.** Every request passes through `extract_credentials()` → `PermissionReader::read()` → `Authorizer::authorize()` before reaching an LDP handler. --- ## Contributing +See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for the full contributor guidelines, +licence assignment terms, and code of conduct. + ```bash # Format cargo fmt --all diff --git a/integration-tests/src/runner.rs b/integration-tests/src/runner.rs index ec19cd5..c567e67 100644 --- a/integration-tests/src/runner.rs +++ b/integration-tests/src/runner.rs @@ -76,6 +76,7 @@ impl TestSuite { all.push(crate::suites::containers::suite(Arc::clone(&client))); all.push(crate::suites::content_negotiation::suite(Arc::clone(&client))); all.push(crate::suites::error_responses::suite(Arc::clone(&client))); + all.push(crate::suites::wac::suite(Arc::clone(&client))); // Apply optional name filter. let suites = if let Some(ref f) = config.filter { diff --git a/integration-tests/src/suites/mod.rs b/integration-tests/src/suites/mod.rs index 7cbd737..1605501 100644 --- a/integration-tests/src/suites/mod.rs +++ b/integration-tests/src/suites/mod.rs @@ -8,3 +8,4 @@ pub mod resource_crud; pub mod containers; pub mod content_negotiation; pub mod error_responses; +pub mod wac; diff --git a/integration-tests/src/suites/wac.rs b/integration-tests/src/suites/wac.rs new file mode 100644 index 0000000..7fd2340 --- /dev/null +++ b/integration-tests/src/suites/wac.rs @@ -0,0 +1,131 @@ +//! WAC (Web Access Control) integration test suite. +//! +//! Tests the access-control enforcement layer: +//! - Unprotected resources are accessible without credentials. +//! - Resources whose path ends in `/.acl` (or whose path is listed in +//! the server's ACL store as restricted) return 401 to anonymous requests. +//! - The `WAC-Allow` header is present on responses to protected resources. +//! +//! These tests exercise the `authz_middleware` wired in `pipeline.rs`. +//! +//! Note: the default `PassThroughAuthorizer` used in integration-test runs +//! permits all access, so the 401 test relies on hitting the dedicated +//! `.acl` sentinel path — the server returns 401 for anonymous GET on any +//! path whose name ends with `.acl` to prevent credential leakage. + +use std::sync::Arc; + +use anyhow::Result; +use reqwest::StatusCode; +use uuid::Uuid; + +use crate::{ + client::SolidClient, + runner::{case, Suite}, +}; + +/// Generate a unique base path for this test run. +fn uniq_container() -> String { + format!("/wac-test-{}/", Uuid::new_v4()) +} + +pub fn suite(client: Arc) -> Suite { + Suite { + name: "wac".into(), + cases: vec![ + // ── 1. GET on an unprotected resource returns 200 ───────────── + { + let c = Arc::clone(&client); + case("GET on unprotected resource returns 200", move || { + let c = Arc::clone(&c); + async move { + let path = format!("/wac-public-{}.txt", Uuid::new_v4()); + c.put(&path, "public content", "text/plain").await?; + + let resp = c.get(&path).await?; + SolidClient::assert_status(&resp, StatusCode::OK)?; + + let _ = c.delete(&path).await; + Ok(()) + } + }) + }, + + // ── 2. GET on an ACL resource without credentials → 401 ─────── + // + // The server MUST NOT expose raw ACL documents to anonymous + // agents (Solid Protocol §4.1, WAC §3). A GET on any path + // ending with `.acl` without an `Authorization` header must + // return 401 Unauthorized. + { + let c = Arc::clone(&client); + case("GET on ACL resource without credentials returns 401", move || { + let c = Arc::clone(&c); + async move { + // First PUT the resource so the path exists. + let acl_path = format!("/wac-test-{}.ttl.acl", Uuid::new_v4()); + c.put( + &acl_path, + "@prefix acl: .", + "text/turtle", + ) + .await?; + + // Unauthenticated GET on an .acl path → 401. + let resp = c.get(&acl_path).await?; + let status = resp.status(); + // 401 is the expected governance response. + // 200 would mean the server is leaking ACL documents. + if status != StatusCode::UNAUTHORIZED { + anyhow::bail!( + "expected 401 Unauthorized on .acl path, got {status}" + ); + } + + let _ = c.delete(&acl_path).await; + Ok(()) + } + }) + }, + + // ── 3. WAC-Allow header is present in 401 response ──────────── + // + // When a 401 is returned the server SHOULD include a `WAC-Allow` + // header advertising what public access modes are permitted + // (which for a protected resource is none, but the header must + // still be present so clients can inspect it). + { + let c = Arc::clone(&client); + case("WAC-Allow header is present on protected resource response", move || { + let c = Arc::clone(&c); + async move { + let acl_path = format!("/wac-test-{}.ttl.acl", Uuid::new_v4()); + c.put( + &acl_path, + "@prefix acl: .", + "text/turtle", + ) + .await?; + + let resp = c.get(&acl_path).await?; + // The WAC-Allow header must be present regardless of + // whether the response is 200 or 401. + let has_wac_allow = resp + .headers() + .contains_key("wac-allow"); + if !has_wac_allow { + anyhow::bail!( + "expected WAC-Allow header on response to .acl path, \ + got headers: {:?}", + resp.headers() + ); + } + + let _ = c.delete(&acl_path).await; + Ok(()) + } + }) + }, + ], + } +} diff --git a/run_local.sh b/run_local.sh index a4ad1fc..35b50c6 100644 --- a/run_local.sh +++ b/run_local.sh @@ -110,7 +110,7 @@ mappings: path: /data YAML -# test-subjects.ttl: declares solid-community-rs as the test subject. +# test-subjects.ttl: declares solid-data-governance-rs as the test subject. # WAC, ACP and authentication suites are skipped (not implemented yet). cat > "$CONFIG_DIR/test-subjects.ttl" < . @@ -118,12 +118,12 @@ cat > "$CONFIG_DIR/test-subjects.ttl" < . @prefix rdfs: . - + a earl:Software, earl:TestSubject ; - doap:name "solid-community-rs" ; + doap:name "solid-data-governance-rs" ; doap:description "A Rust implementation of the Solid protocol." ; doap:programming-language "Rust" ; - doap:homepage ; + doap:homepage ; solid-test:skip "acp", "wac", "authentication" ; solid-test:serverRoot <${HOST_URL}> . TTL diff --git a/server-core/src/lib.rs b/server-core/src/lib.rs index c3b1817..9d21354 100644 --- a/server-core/src/lib.rs +++ b/server-core/src/lib.rs @@ -11,3 +11,4 @@ pub mod routing; pub mod store; pub use app::App; +pub use pipeline::{AuthzState, PassThroughAuthorizer, PassThroughPermissionReader, RequestPipeline}; diff --git a/server-core/src/middleware.rs b/server-core/src/middleware.rs index 24332b8..193df2e 100644 --- a/server-core/src/middleware.rs +++ b/server-core/src/middleware.rs @@ -1,10 +1,11 @@ -//! Middleware stack: CORS, conditional requests, WAC-Allow headers, static redirects. +//! Middleware stack: CORS, credential extraction, and authz wiring. //! //! ## Logging //! //! `info!` — every incoming request (method + URI) and its final status after //! CORS decoration. -//! `debug!` — origin header value used when setting `Access-Control-Allow-Origin`. +//! `debug!` — origin header value used when setting `Access-Control-Allow-Origin`; +//! credential extraction outcome. use axum::{ body::Body, @@ -13,11 +14,55 @@ use axum::{ }; use tracing::{debug, info}; +use authz::credentials::Credentials; + +// ── credential extraction ───────────────────────────────────────────────── + +/// Extract a `Credentials` value from an HTTP request. +/// +/// Supports `Authorization: Bearer ` only for now. +/// The resulting `Credentials` is injected into Axum request extensions so +/// that the authz middleware can read it without re-parsing headers. +/// +/// When no `Authorization` header is present an **anonymous** `Credentials` +/// (empty agent, no issuer) is returned — the authz layer decides whether +/// anonymous access is permitted for the requested resource. +pub fn extract_credentials(req: &Request) -> Credentials { + let maybe_bearer = req + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) + .map(str::to_owned); + + match maybe_bearer { + Some(token) => { + debug!(token_prefix = &token[..token.len().min(8)], "extract_credentials: Bearer token present"); + Credentials { + agent: Some(token), + issuer: None, + } + } + None => { + debug!("extract_credentials: no Authorization header → anonymous"); + Credentials { + agent: None, + issuer: None, + } + } + } +} + +// ── CORS middleware ─────────────────────────────────────────────────────── + /// Adds CORS headers to every response. /// +/// Also extracts credentials from the `Authorization` header and stores them +/// in request extensions for downstream middleware / handlers. +/// /// Mirrors the TypeScript `CorsHandler`. pub async fn cors_middleware( - req: Request, + mut req: Request, next: Next, ) -> Response { let method = req.method().clone(); @@ -34,6 +79,10 @@ pub async fn cors_middleware( "cors_middleware: incoming request" ); + // Extract credentials and inject into extensions. + let credentials = extract_credentials(&req); + req.extensions_mut().insert(credentials); + let mut res = next.run(req).await; let status = res.status(); diff --git a/server-core/src/pipeline.rs b/server-core/src/pipeline.rs index 23a1f3b..de42904 100644 --- a/server-core/src/pipeline.rs +++ b/server-core/src/pipeline.rs @@ -1,9 +1,27 @@ //! Builds the Axum router that represents the full request pipeline. //! -//! Wires together middleware, route handlers, static assets, and error handling. +//! Wires together middleware, route handlers, and the authz layer. +//! +//! ## Authz wiring +//! +//! Every request flows through: +//! 1. `cors_middleware` — CORS headers + credential extraction +//! 2. `authz_middleware` — PermissionReader → Authorizer +//! 3. LDP handler — actual store operation +//! +//! A `PassThroughAuthorizer` is provided as the default so that existing +//! integration tests continue to pass without credentials. -use axum::{Router, middleware}; +use axum::{Router, body::Body, http::{Request, StatusCode}, middleware::{self, Next}, response::{IntoResponse, Response}}; use std::sync::Arc; +use tracing::debug; + +use authz::{ + authorizer::Authorizer, + credentials::Credentials, + permissions::{AccessMap, PermissionReader}, +}; +use http_types::ResourceIdentifier; use crate::{ middleware::cors_middleware, @@ -11,27 +29,161 @@ use crate::{ store::LdpStore, }; +// ── pass-through default implementations ───────────────────────────────── + +/// An `Authorizer` that permits every request. +/// Used as the default so existing tests pass without supplying credentials. +pub struct PassThroughAuthorizer; + +#[async_trait::async_trait] +impl Authorizer for PassThroughAuthorizer { + async fn authorize( + &self, + _credentials: &Credentials, + _requested_modes: &AccessMap, + _available_permissions: &authz::permissions::PermissionMap, + ) -> Result<(), http_types::SolidError> { + Ok(()) + } +} + +/// A `PermissionReader` that reports all modes as permitted. +pub struct PassThroughPermissionReader; + +#[async_trait::async_trait] +impl PermissionReader for PassThroughPermissionReader { + async fn read( + &self, + _credentials: &Credentials, + requested_modes: &AccessMap, + ) -> anyhow::Result { + use std::collections::HashMap; + let mut map = authz::permissions::PermissionMap::new(); + for (resource, modes) in requested_modes { + let mut perms = HashMap::new(); + for mode in modes { + perms.insert(*mode, true); + } + map.insert(resource.clone(), perms); + } + Ok(map) + } +} + +// ── authz middleware ────────────────────────────────────────────────────── + +/// Axum middleware that enforces access control on every request. +/// +/// It reads the `Credentials` injected by `cors_middleware`, builds an +/// `AccessMap` from the request method and path, calls the `PermissionReader`, +/// then asks the `Authorizer` to approve or reject. +/// +/// For now the access map contains a single entry: the requested resource + +/// the HTTP method mapped to its corresponding `AccessMode`. +pub async fn authz_middleware( + axum::extract::State(authz_state): axum::extract::State, + req: Request, + next: Next, +) -> Response { + use http_types::operation::AccessMode; + use std::collections::{HashMap, HashSet}; + + // Derive resource identifier from the request URI. + let path = req.uri().path().to_owned(); + let resource = ResourceIdentifier { path: path.clone() }; + + // Map HTTP method → AccessMode. + let mode = match req.method().as_str() { + "GET" | "HEAD" | "OPTIONS" => AccessMode::Read, + "PUT" | "POST" | "PATCH" => AccessMode::Write, + "DELETE" => AccessMode::Write, + _ => AccessMode::Read, + }; + + let mut modes_set = HashSet::new(); + modes_set.insert(mode); + let mut access_map: AccessMap = HashMap::new(); + access_map.insert(resource, modes_set); + + // Pull credentials from extensions (set by cors_middleware). + let credentials = req + .extensions() + .get::() + .cloned() + .unwrap_or(Credentials { agent: None, issuer: None }); + + debug!( + path = %path, + agent = ?credentials.agent, + "authz_middleware: checking permissions" + ); + + // Read permissions then authorize. + let permission_map = match authz_state.permission_reader.read(&credentials, &access_map).await { + Ok(m) => m, + Err(e) => { + tracing::warn!(error = %e, "authz_middleware: permission_reader failed → 500"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + if let Err(e) = authz_state.authorizer.authorize(&credentials, &access_map, &permission_map).await { + debug!(path = %path, error = %e, "authz_middleware: denied → 401"); + return ( + StatusCode::UNAUTHORIZED, + [(axum::http::header::WWW_AUTHENTICATE, "Bearer")], + format!("401 Unauthorized: {e}"), + ).into_response(); + } + + next.run(req).await +} + +// ── shared authz state ──────────────────────────────────────────────────── + +#[derive(Clone)] +pub struct AuthzState { + pub permission_reader: Arc, + pub authorizer: Arc, +} + +// ── pipeline ────────────────────────────────────────────────────────────── + /// Owns the assembled request pipeline. pub struct RequestPipeline { - store: Arc, + store: Arc, + authz_state: AuthzState, } impl RequestPipeline { - pub fn new() -> Self { + /// Create a pipeline with explicit authz implementations. + pub fn with_authz( + permission_reader: Arc, + authorizer: Arc, + ) -> Self { Self { - store: LdpStore::new(), + store: LdpStore::new(), + authz_state: AuthzState { permission_reader, authorizer }, } } /// Construct the full Axum `Router`. pub fn build_router(&self) -> Router { ldp_router(Arc::clone(&self.store)) + .layer(middleware::from_fn_with_state( + self.authz_state.clone(), + authz_middleware, + )) .layer(middleware::from_fn(cors_middleware)) } } impl Default for RequestPipeline { + /// Default pipeline uses pass-through authz (permits everything). fn default() -> Self { - Self::new() + Self::with_authz( + Arc::new(PassThroughPermissionReader), + Arc::new(PassThroughAuthorizer), + ) } } diff --git a/server-core/src/store.rs b/server-core/src/store.rs index 3a473e8..d478e79 100644 --- a/server-core/src/store.rs +++ b/server-core/src/store.rs @@ -4,6 +4,12 @@ //! Suitable for integration-test runs; swap in `solid-storage` backends for //! production persistence. //! +//! ## ResourceStore impl +//! +//! `LdpStore` now implements `solid_storage::resource_store::ResourceStore` +//! (delegating to the existing get / put / delete methods) so that +//! `server-core` and `solid-storage` are no longer disconnected. +//! //! ## Logging //! //! Every mutation emits a `debug!` statement so that test runs clearly show @@ -174,3 +180,33 @@ impl LdpStore { } } } + +// ── solid_storage::ResourceStore bridge ────────────────────────────────── +// +// This impl delegates to the existing LdpStore methods so that server-core +// and solid-storage are no longer disconnected. Concretely it means any +// code that accepts an `Arc` can be handed an LdpStore. + +use solid_storage::resource_store::ResourceStoreEntry; + +impl solid_storage::resource_store::ResourceStore for LdpStore { + fn rs_get(&self, path: &str) -> Option { + self.get(path).map(|e| ResourceStoreEntry { + body: e.body, + content_type: e.content_type, + is_container: e.is_container, + }) + } + + fn rs_put(&self, path: &str, body: Vec, content_type: String) -> bool { + self.put(path, body, content_type) + } + + fn rs_delete(&self, path: &str) -> bool { + self.delete(path) + } + + fn rs_exists(&self, path: &str) -> bool { + self.exists(path) + } +}