diff --git a/AUTHORS b/AUTHORS index 2bb01d0..07f6fde 100644 --- a/AUTHORS +++ b/AUTHORS @@ -4,3 +4,4 @@ Shashank Singh Jesper Pedersen Adnan Khan Mohamed Hamed +Yousuf Mahmoud diff --git a/README.md b/README.md index 856241f..02c7c9f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - Keycloak Single Sign-On (SSO) authentication with 3 roles (Member, Lead, Admin) - Persistent project registration with public and private projects +- Per-project membership with OWNER and MEMBER roles, to share a project with a team - Manual Linux builds from a chosen branch, run in a container with Docker or Podman - Build history with status, commit ID, timing and output - Private Git access through environment-variable credential references diff --git a/docs/DEVELOPERS.md b/docs/DEVELOPERS.md index e21bb93..0b20cff 100644 --- a/docs/DEVELOPERS.md +++ b/docs/DEVELOPERS.md @@ -71,7 +71,7 @@ See [BUILDING.md](BUILDING.md) for the current development, build, test and fron Cisys uses **Keycloak** for sign-in and bearer-token authentication. * The frontend is a public client (`cisys-ui`) using the authorization-code flow with PKCE. -* The backend is a Quarkus OIDC `service` application. It reads roles from the access token claim `realm_access/roles` and uses the immutable `sub` claim as the principal and project owner. +* The backend is a Quarkus OIDC `service` application. It reads roles from the access token claim `realm_access/roles` and uses the immutable `sub` claim as the principal, as the project owner (`owner_subject`), and for the `project_membership` rows. * The realm roles are `member`, `lead` and `admin`. The development realm is `contrib/keycloak/realm.json`. It is imported on the first Keycloak start only. @@ -212,7 +212,7 @@ But they are not actually lost - using `git reflog`, you can find every commit t ## Verification status * Backend tests (`mvn test`) and the frontend lint and production build pass in the development workspace. -* Keycloak 26.8.2 tokens for Member, Lead and Admin, and private-project visibility, were tested through the REST API against a real local Keycloak and PostgreSQL 18 on 2026-11-07. +* Keycloak 26.8.2 tokens for Member, Lead and Admin, and private-project visibility, were tested through the REST API against a real local Keycloak and PostgreSQL 18 on 2026-11-07. Project membership, member management and the global-role × visibility × membership matrix were tested in the backend suite on 2026-11-14. * An opt-in smoke test fetches the public `octocat/Hello-World` repository via JGit and runs an Alpine container through Docker: `CISYS_RUNNER_SMOKE=true mvn -Dtest=RunnerSmokeTest test`. * Not yet verified: a real private Git provider, Podman, Windows/WSL, and the full Compose stack end to end. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 56a86e7..3b3df85 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,7 +1,7 @@ # Next milestones 1. Finish the authentication slice. Done: Keycloak login, `member`/`lead`/`admin` realm roles, subject-based project ownership, role and private-project authorization tests, and configurable branding, and a Flyway baseline migration with Hibernate schema validation. Remaining: integration tests against a real Keycloak and server configuration. -2. Repository access. Done: JGit fetch of public HTTPS repositories on `github.com` and `gitlab.com`, private access through environment-variable credential references (admins only), and the commit SHA recorded per build, including after a force push. Remaining: private credentials stored using a secrets provider, and project membership and repository permissions. +2. Repository access. Done: JGit fetch of public HTTPS repositories on `github.com` and `gitlab.com`, private access through environment-variable credential references (admins only), the commit SHA recorded per build, including after a force push, and project membership with per-project roles (`OWNER` and `MEMBER`). Remaining: private credentials stored using a secrets provider, and per-repository permissions. 3. Manual builds. Done: a single in-process runner using Docker or Podman with no network, CPU/memory/PID limits, a timeout and stored logs; builds left running by a restart are marked failed. Persistent FIFO queue with stored request details, atomic claims, configurable concurrency and queue limits, and restart recovery. Remaining: cancellation, container cleanup after a crash, and a separately isolated Linux worker using disposable rootless containers with a restricted network and no container-engine socket or host credentials exposed. Define these before enabling untrusted builds. 4. Multi-user deployment: TLS, secure Keycloak settings and secrets, database backups, audit records, access control, rate limits, concurrency controls and tested upgrade/migration procedures. 5. CI configuration, webhooks, artifacts and additional worker platforms. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 5da6e5b..d81c1b7 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -21,13 +21,16 @@ The Keycloak admin console () uses `admin` / `admin | Endpoint | Member | Lead | Admin | | :---------------------------------------- | :------------------------ | :------------------- | :---- | | `GET /api/session` | yes | yes | yes | -| `GET /api/projects` | public projects | public and own | all | +| `GET /api/projects` | public projects and private projects they belong to | public projects and private projects they belong to | all | | `POST /api/projects` | no | yes | yes | | `GET /api/projects/{id}/builds[/{id}]` | visible projects | visible projects | all | | `GET /api/projects/{id}/branches` | visible projects | visible projects | all | -| `POST /api/projects/{id}/builds` | no | yes | yes | +| `POST /api/projects/{id}/builds` | no | project members | yes | | `POST` build with a `credentialRef` | no | no | yes | -| `PUT`/`DELETE .../builds/{id}/pin` | no | own projects | yes | +| `PUT`/`DELETE .../builds/{id}/pin` | no | leads who own the project | yes | +| `GET /api/projects/{id}/members` | project members | project members | yes | +| `POST /api/projects/{id}/members` | project owners | project owners | yes | +| `DELETE /api/projects/{id}/members/{username}` | project owners | project owners | yes | | `GET /api/branding` | yes | yes | yes | | `PUT /api/branding` | no | no | yes | | `GET`/`PUT /api/settings/retention` | no | no | yes | @@ -36,9 +39,17 @@ The Keycloak admin console () uses `admin` / `admin | `PUT /api/images` | no | no | yes | | `GET/POST/DELETE /api/images/local` | no | no | yes | -Without signing in, `GET /api/branding/public`, `GET /api/branding/logo`, `GET /api/stats/public`, the public project page (`GET /api/project-pages/{name}`, `GET /api/project-pages/{name}/builds/{buildId}` and its build logs) and `GET /api/project-pages/{name}/branches` for public projects are readable. The project page accepts an optional `branch` query parameter, which filters the builds but applies the same public/private visibility rules. The statistics are aggregate counts computed from public projects only (public projects, their builds by status, success rate, last build time). Private projects and their builds are left out of every number, and no names, repository URLs, logs or users are exposed. The server caches the result for `STATS_CACHE_SECONDS` (default 30) seconds. The branch endpoint returns remote branch names for public projects. For private projects it requires the same owner/admin access as the page and returns only the branches that already have builds, so the private repository is never read; anonymous callers and users without access receive `404`. +Without signing in, `GET /api/branding/public`, `GET /api/branding/logo`, `GET /api/stats/public`, the public project page (`GET /api/project-pages/{name}`, `GET /api/project-pages/{name}/builds/{buildId}` and its build logs) and `GET /api/project-pages/{name}/branches` for public projects are readable. The project page accepts an optional `branch` query parameter, which filters the builds but applies the same public/private visibility rules. The statistics are aggregate counts computed from public projects only (public projects, their builds by status, success rate, last build time). Private projects and their builds are left out of every number, and no names, repository URLs, logs or users are exposed. The server caches the result for `STATS_CACHE_SECONDS` (default 30) seconds. The branch endpoint returns remote branch names for public projects. For private projects it requires the same member/admin access as the page and returns only the branches that already have builds, so the private repository is never read; anonymous callers and users without access receive `404`. -Private projects (and their build history and logs) are visible only to their creator and admins. Ownership is recorded with the immutable Keycloak subject (`sub`), not the user name. A project that is not visible to the caller is reported as `404 Not Found`. +Private projects (and their build history and logs) are visible only to their members and to admins. Membership is recorded with the immutable Keycloak subject (`sub`), not the user name. A project always has exactly one `OWNER` member — the project's creator — and any number of `MEMBER` members. A project that is not visible to the caller is reported as `404 Not Found`. + +### Project membership + +* Every project has exactly one `OWNER` member, its creator, recorded atomically when the project is created. Other members hold the `MEMBER` role. Ownership cannot be transferred or removed through the API; removing the `OWNER` answers `409 Conflict`. +* Starting builds requires the Lead or Admin role, and a project membership; admins bypass membership. Editing a project and pinning its builds require the Lead role and being the project's `OWNER`, or the Admin role. Apart from member management by the `OWNER`, project membership never grants permissions the caller's global role does not allow — the global role decides what you can do, membership decides on which projects. +* Listing members (`GET /api/projects/{id}/members`) requires being a member of the project, or an admin. Adding (`POST`) and removing (`DELETE`) members requires being the project's `OWNER`, whatever the global role, or an admin. Only the `MEMBER` role is assignable; the `OWNER` role is never assignable through the membership API, and raw subject IDs are never exposed — the API accepts and returns usernames. +* Member-management endpoints resolve usernames to Keycloak through the `cisys-backend` service account; the membership endpoints only read users, but the account holds Keycloak's `manage-users` role, which signing users out on shutdown needs, so keep its secret protected. +* `ownerSubject` is kept as a denormalized pointer for the `owned` flag and for admins; project membership is the source of truth for authorization. User subjects are returned to admins only: diff --git a/docs/entity-diagram.md b/docs/entity-diagram.md index 14c8321..00bebfe 100644 --- a/docs/entity-diagram.md +++ b/docs/entity-diagram.md @@ -48,12 +48,23 @@ erDiagram INTEGER max_queued_builds } + PROJECT_MEMBERSHIP { + BIGINT id PK + BIGINT project_id + STRING subject + STRING role + TIMESTAMP created_at + } + PROJECT ||--o{ BUILD : has + PROJECT ||--o{ PROJECT_MEMBERSHIP : has ``` -* `PROJECT.owner_subject` and `BUILD.requested_by` hold the Keycloak subject (`sub`) of the user. +* `PROJECT.owner_subject` and `BUILD.requested_by` hold the Keycloak subject (`sub`) of the user. `PROJECT_MEMBERSHIP.subject` holds the same kind of subject; exactly one row per project has role `OWNER` — the creator, recorded atomically with project creation. +* `PROJECT_MEMBERSHIP.project_id` is a foreign key to `PROJECT` with `ON DELETE CASCADE`; a unique index over `(project_id, subject)` and a partial unique index on `project_id` for `OWNER` rows enforce one membership per user per project and one OWNER per project. +* `OWNER` rows are never assignable through the membership API and one must always exist; there is no ownership-transfer path. * `BUILD.project_id` is a plain column, not a foreign key constraint. * `BUILD.logoid` (type `lo`, from the `lo` extension) references the build log, stored as one PostgreSQL large object. It is not mapped in the `Build` entity. The `lo_manage` trigger unlinks the large object when the build is deleted, so no logs are orphaned. -* `BUILD.status` is `RUNNING`, `PASSED` or `FAILED`. +* `BUILD.status` is `QUEUED`, `RUNNING`, `PASSED` or `FAILED`. * `BRANDING` and `SETTINGS` are singleton rows with `id = 1`. Until `SETTINGS` is first saved, its defaults apply. * Users, roles and credentials live in Keycloak and are not stored in the cisys database. diff --git a/docs/manual/en/01-introduction.md b/docs/manual/en/01-introduction.md index bcf19ae..927003d 100644 --- a/docs/manual/en/01-introduction.md +++ b/docs/manual/en/01-introduction.md @@ -12,6 +12,7 @@ It provides sign-in through Keycloak, three roles, project registration with pub * Keycloak Single Sign-On (SSO) authentication with 3 roles (Member, Lead, Admin) * Persistent project registration with public and private projects +* Per-project membership with OWNER and MEMBER roles; owners and admins manage project members * Manual Linux builds from a chosen branch, run in a container through Docker or Podman * Build history with status, commit ID, timing and output * Private Git access through environment-variable credential references @@ -22,8 +23,8 @@ It provides sign-in through Keycloak, three roles, project registration with pub ## Roles * **Members** can view public projects and the build history of projects they can access. -* **Leads** can additionally create projects and start manual builds. -* **Admins** can see every project, change the branding, and use credential references for private Git. +* **Leads** can additionally create projects and start manual builds on projects they are members of. +* **Admins** can see every project, start and pin builds without membership, change the branding, and use credential references for private Git. ## Project handling @@ -31,7 +32,7 @@ Each project keeps * A name and an HTTPS repository URL * A private flag, which controls project and build-log visibility -* The owner, recorded as the Keycloak subject +* The OWNER member (the creator) and any MEMBER members, who can see private projects and their builds ## Build handling diff --git a/docs/manual/en/03-roles.md b/docs/manual/en/03-roles.md index e88fb64..6c0e880 100644 --- a/docs/manual/en/03-roles.md +++ b/docs/manual/en/03-roles.md @@ -10,23 +10,24 @@ The **Member** role is the standard read-only role. Members can view public proj ## Lead -The **Lead** role is for people who run projects. Leads can do everything a member can, create projects, start manual builds on projects they can see, and pin builds of the projects they created. +The **Lead** role is for people who run projects. Leads can do everything a member can, create projects, start manual builds on projects they are members of (a project owner adds them), and edit and pin builds of the projects they own. ## Admin -The **Admin** role manages the installation. Admins can see every project including private projects of other users, start and pin builds, use credential references for private Git repositories, manage the build images, change the branding, and change the settings such as build retention and the build queue. +The **Admin** role manages the installation. Admins can see every project, start and pin builds without membership, use credential references for private Git repositories, manage the build images, change the branding, manage project membership, and change the settings such as build retention and the build queue. ## Access summary | Action | Member | Lead | Admin | | :---------------------------------- | :----- | :--- | :---- | | View public projects | yes | yes | yes | -| View own private projects | n/a | yes | yes | +| View private projects they belong to | yes | yes | yes | | View other users' private projects | no | no | yes | | Create a project | no | yes | yes | | View build history and logs | yes | yes | yes | -| Start a manual build | no | yes | yes | -| Pin a build | no | own | yes | +| Start a manual build | no | member projects | yes | +| Pin a build | no | projects they own | yes | +| Add or remove project members | projects they own | projects they own | yes | | Use a Git credential reference | no | no | yes | | Manage build images | no | no | yes | | Change branding | no | no | yes | @@ -34,4 +35,6 @@ The **Admin** role manages the installation. Admins can see every project includ "View" always means projects the user may access. The address of a project or build that does not exist or is not visible to the user opens the front page instead, so a private project is not revealed; the same applies to any unknown address. +Project membership scopes the projects a global role applies to; MEMBER membership does not grant powers on its own. Starting a build requires the Lead or Admin role **and** membership of the project — someone who is only a MEMBER of a project has no Lead powers. Every project has exactly one OWNER, its creator. The OWNER can add and remove members whatever their global role; admins can do the same on any project. Editing a project and pinning its builds need more: the Lead role **and** being the project's OWNER, or the Admin role. The OWNER role is never assignable through the membership API, and the project's OWNER cannot be removed. + The following chapters describe the application areas in more detail. diff --git a/docs/manual/en/04-projects.md b/docs/manual/en/04-projects.md index 9609625..03187a7 100644 --- a/docs/manual/en/04-projects.md +++ b/docs/manual/en/04-projects.md @@ -9,7 +9,9 @@ A **project** registers a Git repository with cisys so that it can be built. ## Visibility * **Public** projects are visible to every signed-in user. -* **Private** projects are visible only to their creator and to admins. +* **Private** projects are visible only to their members and to admins. + +Private projects have an **OWNER** member (the creator) and any number of **MEMBER** members. The project's owner, whatever their global role, and admins add and remove members through the membership API; the interface for this is planned, for now it is available at the API (`GET`/`POST`/`DELETE /api/projects/{id}/members[/{username}]`). The private flag controls visibility of the project and its build logs inside cisys. It does not grant or restrict access to the Git repository itself. @@ -23,7 +25,7 @@ Selecting a branch in the left panel filters the build history to that branch. T Selecting a build opens its own page at `///build/`, for example `http://localhost:5173/cisys/master/build/51`, with the build details and the full log. -Private projects use the same page once you sign in as their owner or an administrator; everyone else is told the project does not exist. The branches panel lists the branches that already have builds, because reading a private repository requires credentials. +Private projects use the same page once you sign in as a member of the project (OWNER or MEMBER) or an administrator; everyone else is told the project does not exist. The branches panel lists the branches that already have builds, because reading a private repository requires credentials. ## Create a project @@ -39,10 +41,12 @@ Projects created before this naming rule keep working. An administrator can rena UPDATE project SET name = 'new-name' WHERE name = 'old name'; ``` -The creator's Keycloak subject is stored as the owner. +The creator's Keycloak subject is stored as the owner, and the creator becomes the project's `OWNER` member in the same transaction; adding or removing members never leaves the project without an owner. The repository must be on an allowed host and must not resolve to a loopback, private, link-local or metadata address. By default only `github.com` and `gitlab.com` are allowed; the backend settings `cisys.git.allowed-hosts` and `cisys.git.allowed-networks` extend that list. ## Ownership -Ownership uses the immutable Keycloak subject ID, not the user name. Development projects created before subject-based ownership was introduced must be reset or migrated to Keycloak subject IDs. +Ownership uses the immutable Keycloak subject ID, not the user name. A project has exactly one OWNER member — its creator — plus any number of MEMBER members. The membership API cannot assign or remove the OWNER role, and the project's OWNER cannot be removed. Editing the project and pinning its builds require the Lead role and ownership, or the Admin role. + +Projects created before project membership existed have no `OWNER` membership, and the schema migration does not add one. After upgrading to this version, the database must be recreated with `make reset`; this deletes the local PostgreSQL and Keycloak data, including all existing projects and builds. diff --git a/docs/manual/en/05-builds.md b/docs/manual/en/05-builds.md index f7b83f6..c0876fb 100644 --- a/docs/manual/en/05-builds.md +++ b/docs/manual/en/05-builds.md @@ -13,7 +13,7 @@ Leads and admins select **Build** next to a branch on a project page. That runs * **Branch**: the branch to fetch * **Credential reference** (admins only, optional): see below -Members can view build history and logs for the projects they can access. Private projects and their logs are visible only to their creator and admins. +Members can view build history and logs for the projects they can access. Private projects and their logs are visible only to their members and admins. Starting a build requires the Lead or Admin role and membership of the project — a project owner adds a lead to the project. Admins can build without membership. ## How a build runs @@ -48,7 +48,7 @@ The complete output of the build, standard output and standard error together, i ## Pinned builds -The project's creator and admins can **Pin** a build on its build page, and **Unpin** it again. A pinned build, with its log, is kept permanently; build retention cleanup skips it. Pinned builds are marked with a pin icon in the build history. +A lead who is the project's OWNER, and admins, can **Pin** a build on its build page, and **Unpin** it again. A pinned build, with its log, is kept permanently; build retention cleanup skips it. Pinned builds are marked with a pin icon in the build history. ## Build retention diff --git a/docs/manual/en/07-security.md b/docs/manual/en/07-security.md index ee12281..7f3372e 100644 --- a/docs/manual/en/07-security.md +++ b/docs/manual/en/07-security.md @@ -50,7 +50,7 @@ These credentials, HTTP, and Keycloak development mode are for a trusted local m ## Authorization -Private projects and their build logs are visible only to their creator and admins. Starting builds requires the Lead or Admin role. Changing branding, and using Git credential references, require the Admin role. The full endpoint mapping is in `docs/SECURITY.md`. +Private projects and their build logs are visible only to their members and admins. Starting builds requires the Lead or Admin role and membership of the project — admins may build without membership. Changing branding, and using Git credential references, require the Admin role. The endpoint mapping is in `docs/SECURITY.md`. ## Build runner diff --git a/docs/manual/en/72-architecture.md b/docs/manual/en/72-architecture.md index 263e4f0..d6d6609 100644 --- a/docs/manual/en/72-architecture.md +++ b/docs/manual/en/72-architecture.md @@ -15,7 +15,7 @@ The sources are in `src/backend/main/java/ai/mnemosyne/cisys`. * `BuildRetention` is a [scheduled](https://quarkus.io/guides/scheduler) job that deletes each project's expired, unpinned builds in batches with one SQL statement per batch; the `lo_manage` trigger removes their logs. * Authentication uses [Quarkus OIDC](https://quarkus.io/guides/security-oidc-bearer-token-authentication) in `service` mode with Keycloak realm roles. -Quarkus configuration is in `src/backend/main/resources/application.properties`. The schema is managed by [Flyway](https://quarkus.io/guides/flyway) migrations in `src/backend/main/resources/db/migration`, applied at startup; Hibernate only validates it. Databases created before migrations existed are adopted automatically. Until the 1.0 release the whole schema is one repeatable migration, `R__schema.sql`, which is changed in place and must stay idempotent; Flyway reruns it whenever it changes, so existing databases never need a manual reset. +Quarkus configuration is in `src/backend/main/resources/application.properties`. The schema is managed by [Flyway](https://quarkus.io/guides/flyway) migrations in `src/backend/main/resources/db/migration`, applied at startup; Hibernate only validates it. Databases created before migrations existed are adopted automatically. Until the 1.0 release the whole schema is one repeatable migration, `R__schema.sql`, which is changed in place and must stay idempotent; Flyway reruns it whenever it changes. It only adds schema and does not convert existing data, so after a change that needs existing rows converted, the database must be recreated with `make reset`. Project membership is such a change: projects created before it have no `OWNER` membership. ## Frontend @@ -74,6 +74,16 @@ erDiagram } PROJECT ||--o{ BUILD : has + + PROJECT_MEMBERSHIP { + BIGINT id PK + BIGINT project_id + STRING subject + STRING role + TIMESTAMP created_at + } + + PROJECT ||--o{ PROJECT_MEMBERSHIP : has ``` -Users and roles are managed in Keycloak; `owner_subject` and `requested_by` hold the Keycloak subject. +Users and roles are managed in Keycloak; `owner_subject`, `requested_by` and `project_membership.subject` hold the Keycloak subject. Each project has exactly one `OWNER` member — its creator — and any number of `MEMBER` members; membership is resolved to user names at the membership API boundary, subjects are never exposed to other users. diff --git a/docs/manual/en/97-acknowledgement.md b/docs/manual/en/97-acknowledgement.md index 1eb945f..becea7d 100644 --- a/docs/manual/en/97-acknowledgement.md +++ b/docs/manual/en/97-acknowledgement.md @@ -11,6 +11,7 @@ Shashank Singh Jesper Pedersen Adnan Khan Mohamed Hamed +Yousuf Mahmoud ``` ## Contributing diff --git a/src/backend/main/java/ai/mnemosyne/cisys/BranchResource.java b/src/backend/main/java/ai/mnemosyne/cisys/BranchResource.java index 57c923c..16777aa 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/BranchResource.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/BranchResource.java @@ -9,18 +9,13 @@ package ai.mnemosyne.cisys; import io.quarkus.security.Authenticated; -import io.quarkus.security.identity.SecurityIdentity; import jakarta.inject.Inject; -import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.GET; -import jakarta.ws.rs.NotAuthorizedException; -import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import org.eclipse.microprofile.jwt.JsonWebToken; import java.util.List; import java.util.Map; @@ -29,22 +24,14 @@ @Produces(MediaType.APPLICATION_JSON) @Authenticated public class BranchResource { - @Inject SecurityIdentity identity; + @Inject ProjectAccess access; @Inject GitRepositoryService repositories; public record Branches(List branches, String reason) {} - private Project visible(Long id) { - if (!(identity.hasRole("member") || identity.hasRole("lead") || identity.hasRole("admin"))) throw new ForbiddenException(); - if (!(identity.getPrincipal() instanceof JsonWebToken jwt) || jwt.getSubject() == null) throw new NotAuthorizedException("Bearer"); - Project p = Project.findById(id); - if (p == null || (p.privateRepository && !identity.hasRole("admin") && !jwt.getSubject().equals(p.ownerSubject))) throw new NotFoundException(); - return p; - } - @GET public Response list(@PathParam("projectId") Long projectId) { - Project project = visible(projectId); + Project project = access.visible(projectId); if (project.privateRepository) { return Response.ok(new Branches(List.of(), "private_repository")).build(); } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/BuildResource.java b/src/backend/main/java/ai/mnemosyne/cisys/BuildResource.java index 5f6ef20..6b977f3 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/BuildResource.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/BuildResource.java @@ -15,13 +15,13 @@ import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import org.eclipse.microprofile.jwt.JsonWebToken; import java.time.Instant; import java.util.List; @Path("/api/projects/{projectId}/builds") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) @Authenticated public class BuildResource { @Inject SecurityIdentity identity; + @Inject ProjectAccess access; @Inject BuildRunner runner; @Inject BuildLogs logs; @Inject RepositoryUrlValidator repositoryUrls; @@ -42,20 +42,13 @@ private BuildResponse response(Build build,boolean withWorkflow) { build.image, admin ? build.credentialRef : null, build.imageDigest, build.exitCode, build.logTruncated, build.pinned, withWorkflow ? build.workflow : null); } - private Project visible(Long id) { - if (!(identity.hasRole("member") || identity.hasRole("lead") || identity.hasRole("admin"))) throw new ForbiddenException(); - if (!(identity.getPrincipal() instanceof JsonWebToken jwt) || jwt.getSubject()==null) throw new NotAuthorizedException("Bearer"); - Project p=Project.findById(id); - if(p==null || (p.privateRepository && !identity.hasRole("admin") && !jwt.getSubject().equals(p.ownerSubject))) throw new NotFoundException(); - return p; - } @GET public List list(@PathParam("projectId") Long id) { - visible(id); + access.visible(id); return Build.find("projectId = ?1 order by id desc",id).page(0,50).list() .stream().map(this::response).toList(); } @GET @Path("/{buildId}") public BuildResponse detail(@PathParam("projectId") Long id,@PathParam("buildId") Long buildId) { - visible(id); Build b=Build.findById(buildId);if(b==null || !id.equals(b.projectId))throw new NotFoundException();return response(b,true); + access.visible(id); Build b=Build.findById(buildId);if(b==null || !id.equals(b.projectId))throw new NotFoundException();return response(b,true); } /** * A running build returns what is stored so far. Authenticated callers get the whole log by @@ -77,19 +70,17 @@ private Project visible(Long id) { } /** Only the project's owner and admins may pin. */ private BuildResponse setPinned(Long id,Long buildId,boolean pinned) { - Project p=visible(id); - if(!identity.hasRole("admin") && !((JsonWebToken)identity.getPrincipal()).getSubject().equals(p.ownerSubject))throw new ForbiddenException(); + Project p=access.editable(id); Build b=Build.findById(buildId);if(b==null || !id.equals(b.projectId))throw new NotFoundException(); b.pinned=pinned; return response(b,true); } @POST public BuildResponse start(@PathParam("projectId") Long id,RunRequest input) { - Project p=visible(id); - if (!(identity.hasRole("lead") || identity.hasRole("admin"))) throw new ForbiddenException(); + Project p=access.buildable(id); if(input==null || input.branch()==null || !org.eclipse.jgit.lib.Repository.isValidRefName("refs/heads/"+input.branch()) || input.branch().startsWith("-") || input.branch().length()>150)throw new BadRequestException("Invalid branch"); if(input.credentialRef()!=null && !identity.hasRole("admin"))throw new ForbiddenException(); if(input.credentialRef()!=null && !input.credentialRef().matches("CISYS_GIT_[A-Z0-9_]{1,80}"))throw new BadRequestException("Invalid credential reference"); try{repositoryUrls.validate(p.repositoryUrl);}catch(IllegalArgumentException e){throw new BadRequestException(e.getMessage());} - return response(runner.submit(id,((JsonWebToken)identity.getPrincipal()).getSubject(),p.repositoryUrl,input)); + return response(runner.submit(id,access.subject(),p.repositoryUrl,input)); } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/KeycloakUsers.java b/src/backend/main/java/ai/mnemosyne/cisys/KeycloakUsers.java new file mode 100644 index 0000000..1120fb1 --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne/cisys/KeycloakUsers.java @@ -0,0 +1,96 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THIS PROGRAM CONSTITUTING RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne.cisys; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.logging.Log; +import jakarta.enterprise.context.ApplicationScoped; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +/** Resolves usernames and subjects through the Keycloak admin API, using the cisys-backend service account. */ +@ApplicationScoped +public class KeycloakUsers { + private static final Duration TIMEOUT = Duration.ofSeconds(5); + private static final Pattern ACCESS_TOKEN = Pattern.compile("\"access_token\"\\s*:\\s*\"([^\"]+)\""); + private final ObjectMapper json = new ObjectMapper(); + + @ConfigProperty(name = "cisys.keycloak.url") String keycloakUrl; + @ConfigProperty(name = "cisys.keycloak.realm", defaultValue = "cisys") String realm; + @ConfigProperty(name = "cisys.keycloak.admin-client-id") String clientId; + @ConfigProperty(name = "cisys.keycloak.admin-client-secret") String clientSecret; + + /** The subject ("id") of the user with this username, or null when there is no such user. */ + public String subjectOf(String username) { + String body = request("GET", "/admin/realms/" + realm + "/users?username=" + encode(username) + "&exact=true", token()); + try { + JsonNode users = json.readTree(body); + if (!users.isArray() || users.isEmpty()) return null; + JsonNode id = users.get(0).get("id"); + return id == null ? null : id.asText(); + } catch (Exception e) { + throw new IllegalStateException("Keycloak returned an unreadable user list: " + e, e); + } + } + + /** The display name for a subject, or "unknown" when Keycloak cannot provide one. Never throws. */ + public String usernameOf(String subject) { + try { + JsonNode user = json.readTree(request("GET", "/admin/realms/" + realm + "/users/" + encode(subject), token())); + JsonNode username = user.get("username"); + return username == null ? "unknown" : username.asText(); + } catch (Exception e) { + Log.warnf("Could not resolve subject '%s' to a username: %s", subject, e.toString()); + return "unknown"; + } + } + + private String token() { + String form = "grant_type=client_credentials&client_id=" + encode(clientId) + "&client_secret=" + encode(clientSecret); + String body = request("POST", "/realms/" + realm + "/protocol/openid-connect/token", null, form); + Matcher matcher = ACCESS_TOKEN.matcher(body); + if (!matcher.find()) throw new IllegalStateException("Keycloak refused the cisys-backend service account"); + return matcher.group(1); + } + + private String request(String method, String path, String bearer) { + return request(method, path, bearer, null); + } + + private String request(String method, String path, String bearer, String form) { + try { + HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(keycloakUrl + path)).timeout(TIMEOUT); + builder.method(method, form == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(form)); + if (bearer != null) builder.header("Authorization", "Bearer " + bearer); + if (form != null) builder.header("Content-Type", "application/x-www-form-urlencoded"); + HttpResponse resp = HttpClient.newHttpClient().send(builder.build(), HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() / 100 != 2) throw new IllegalStateException("Keycloak answered HTTP " + resp.statusCode() + " for " + path); + return resp.body(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while talking to Keycloak", e); + } catch (IOException | IllegalArgumentException e) { + throw new IllegalStateException("Could not reach Keycloak: " + e, e); + } + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/src/backend/main/java/ai/mnemosyne/cisys/ProjectAccess.java b/src/backend/main/java/ai/mnemosyne/cisys/ProjectAccess.java index 4f2ac09..8af5624 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/ProjectAccess.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/ProjectAccess.java @@ -3,7 +3,7 @@ * * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION - * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + * OF THIS PROGRAM CONSTITUTING RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. */ package ai.mnemosyne.cisys; @@ -11,14 +11,16 @@ import io.quarkus.security.identity.SecurityIdentity; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.NotFoundException; +import java.util.List; import java.util.Locale; import org.eclipse.microprofile.jwt.JsonWebToken; /** - * Visibility rules shared by project pages, their builds, logs and branches. - * Private projects are only readable by their owner and admins; everyone else gets a 404, - * so the existence of a private project is not revealed. + * Private projects are visible to members and admins only; everyone else gets a 404, so their + * existence is not revealed. Visible but not allowed actions answer 403. Admins bypass membership. */ @ApplicationScoped public class ProjectAccess { @@ -30,10 +32,91 @@ public Project visible(String name) { return p; } - /** Same rule as the authenticated project and build endpoints: a Cisys role, and admin or owner. */ + public Project visible(Long id) { + requireCisysRole(); + Project p = Project.findById(id); + if (p == null || (p.privateRepository && !canSeePrivate(p))) throw new NotFoundException(); + return p; + } + + public List listVisible() { + requireCisysRole(); + if (isAdmin()) return Project.listAll(); + return Project.list("privateRepository = false or id in (select projectId from ProjectMembership where subject = ?1)", subject()); + } + + public boolean isAdmin() { return identity.hasRole("admin"); } + + public boolean isMember(Project p) { return membership(p) != null; } + + public boolean isOwner(Project p) { + ProjectMembership m = membership(p); + return m != null && m.role == ProjectMembership.MembershipRole.OWNER; + } + + public Project buildable(Long id) { + Project p = visible(id); + if (isAdmin()) return p; + if (!identity.hasRole("lead")) throw new ForbiddenException(); + if (!isMember(p)) throw new ForbiddenException(); + return p; + } + + public Project editable(Long id) { + Project p = visible(id); + if (canEdit(p)) return p; + throw new ForbiddenException(); + } + + /** Editing, pinning included: admins, or a lead who owns the project. */ + public boolean canEdit(Project p) { + return isAdmin() || (identity.hasRole("lead") && isOwner(p)); + } + + /** Pinning a build is an edit of its project; the project page uses this for its Pin button. */ + public boolean canPin(Project p) { return canEdit(p); } + + // Keeps the existing semantics: global lead or admin first, then owner or admin. + public Project deletable(Long id) { + if (!(identity.hasRole("lead") || identity.hasRole("admin"))) throw new ForbiddenException(); + Project p = visible(id); + if (isAdmin() || isOwner(p)) return p; + throw new ForbiddenException(); + } + + public Project manageable(Long id) { + Project p = visible(id); + if (isAdmin() || isOwner(p)) return p; + throw new ForbiddenException(); + } + + public boolean canViewMembers(Project p) { return isAdmin() || isMember(p); } + boolean canSeePrivate(Project p) { - if (identity.isAnonymous() || !(identity.getPrincipal() instanceof JsonWebToken jwt) || jwt.getSubject() == null) return false; + String sub = subjectOrNull(); + if (sub == null) return false; if (!(identity.hasRole("member") || identity.hasRole("lead") || identity.hasRole("admin"))) return false; - return identity.hasRole("admin") || jwt.getSubject().equals(p.ownerSubject); + return isAdmin() || membership(p) != null; + } + + private ProjectMembership membership(Project p) { + String sub = subjectOrNull(); + if (sub == null) return null; + return ProjectMembership.find("projectId = ?1 and subject = ?2", p.id, sub).firstResult(); + } + + private String subjectOrNull() { + if (identity.isAnonymous() || !(identity.getPrincipal() instanceof JsonWebToken jwt)) return null; + return jwt.getSubject(); + } + + String subject() { + String sub = subjectOrNull(); + if (sub == null || sub.isBlank()) throw new NotAuthorizedException("Bearer"); + return sub; + } + + void requireCisysRole() { + if (!(identity.hasRole("member") || identity.hasRole("lead") || identity.hasRole("admin"))) throw new ForbiddenException(); } } diff --git a/src/backend/main/java/ai/mnemosyne/cisys/ProjectMembership.java b/src/backend/main/java/ai/mnemosyne/cisys/ProjectMembership.java new file mode 100644 index 0000000..a7d2649 --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne/cisys/ProjectMembership.java @@ -0,0 +1,26 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THIS PROGRAM CONSTITUTING RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne.cisys; + +import io.quarkus.hibernate.orm.panache.PanacheEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.EnumType; +import java.time.Instant; + +/** One row per member per project; exactly one OWNER row per project. */ +@Entity +public class ProjectMembership extends PanacheEntity { + public Long projectId; + public String subject; + @Enumerated(EnumType.STRING) public MembershipRole role; + public Instant createdAt; + + public enum MembershipRole { MEMBER, OWNER } +} diff --git a/src/backend/main/java/ai/mnemosyne/cisys/ProjectMembershipResource.java b/src/backend/main/java/ai/mnemosyne/cisys/ProjectMembershipResource.java new file mode 100644 index 0000000..f176f1b --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne/cisys/ProjectMembershipResource.java @@ -0,0 +1,93 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THIS PROGRAM CONSTITUTING RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne.cisys; + +import io.quarkus.logging.Log; +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.util.List; +import java.util.Map; + +/** Project membership: list, add and remove members. Subjects never leave the server; usernames do. */ +@Path("/api/projects/{projectId}/members") +@Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) +@Authenticated +public class ProjectMembershipResource { + @Inject ProjectAccess access; + @Inject KeycloakUsers keycloak; + + public record MemberResponse(String username, String role) {} + public record AddMember(String username) {} + + @GET + public List list(@PathParam("projectId") Long id) { + Project p = access.visible(id); + if (!access.canViewMembers(p)) throw new ForbiddenException(); + return ProjectMembership.find("projectId = ?1 order by id", id).list() + .stream().map(m -> new MemberResponse(keycloak.usernameOf(m.subject), m.role.name())).toList(); + } + + @POST @Transactional + public Response add(@PathParam("projectId") Long id, AddMember input) { + access.manageable(id); + if (input == null || input.username() == null || input.username().isBlank()) throw new BadRequestException("username is required"); + String username = input.username().trim(); + String subject; + try { subject = keycloak.subjectOf(username); } + catch (IllegalStateException e) { throw badGateway(e); } + if (subject == null) throw new NotFoundException("No user named '" + username + "' exists in Keycloak"); + if (ProjectMembership.count("projectId = ?1 and subject = ?2", id, subject) > 0) + throw conflict("'" + username + "' is already a member of this project"); + ProjectMembership m = new ProjectMembership(); + m.projectId = id; + m.subject = subject; + m.role = ProjectMembership.MembershipRole.MEMBER; + m.createdAt = java.time.Instant.now(); + m.persist(); + return Response.status(201).entity(new MemberResponse(username, "MEMBER")).build(); + } + + @DELETE @Path("/{username}") @Transactional + public Response remove(@PathParam("projectId") Long id, @PathParam("username") String username) { + access.manageable(id); + String subject; + try { subject = keycloak.subjectOf(username); } + catch (IllegalStateException e) { throw badGateway(e); } + if (subject == null) throw new NotFoundException(); + ProjectMembership m = ProjectMembership.find("projectId = ?1 and subject = ?2", id, subject).firstResult(); + if (m == null) throw new NotFoundException("'" + username + "' is not a member of this project"); + if (m.role == ProjectMembership.MembershipRole.OWNER) throw conflict("The project owner cannot be removed"); + m.delete(); + return Response.noContent().build(); + } + + /** The details name Keycloak's admin endpoints, so they are logged and the client gets a generic message. */ + private static jakarta.ws.rs.WebApplicationException badGateway(IllegalStateException e) { + Log.warnf("Keycloak user lookup failed: %s", e.getMessage()); + return new jakarta.ws.rs.WebApplicationException(Response.status(502).type(MediaType.APPLICATION_JSON).entity(Map.of("error", "Keycloak is unavailable; try again later")).build()); + } + + private static jakarta.ws.rs.WebApplicationException conflict(String message) { + return new jakarta.ws.rs.WebApplicationException(Response.status(409).type(MediaType.APPLICATION_JSON).entity(Map.of("error", message)).build()); + } +} diff --git a/src/backend/main/java/ai/mnemosyne/cisys/ProjectPageResource.java b/src/backend/main/java/ai/mnemosyne/cisys/ProjectPageResource.java index 849fb22..48d38f6 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/ProjectPageResource.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/ProjectPageResource.java @@ -27,7 +27,7 @@ /** * The page of a single project, addressed by its name (for example /cisys/ in the UI). * Public projects are readable by anyone, including visitors who are not signed in. - * Private projects are only readable by their owner and admins; everyone else gets a 404, + * Private projects are only readable by their members and admins; everyone else gets a 404, * so the existence of a private project is not revealed. */ @Path("/api/project-pages/{name}") @@ -56,8 +56,8 @@ public ProjectPage page(@PathParam("name") String name, @QueryParam("branch") St List rows = branchFilter.isEmpty() ? Build.find("projectId = ?1 order by id desc", p.id).page(0, 50).list() : Build.find("projectId = ?1 and branch = ?2 order by id desc", p.id, branchFilter).page(0, 50).list(); - // Whoever may see a private project may also pin its builds. - boolean canPin = access.canSeePrivate(p); + // Pinning follows the same rule as the pin endpoint: admins, or a lead who owns the project. + boolean canPin = access.canPin(p); List builds = rows.stream().map(b -> pageBuild(b, canPin, false)).toList(); return new ProjectPage(new ProjectInfo(p.id, p.name, p.repositoryUrl, p.privateRepository), builds); } @@ -69,7 +69,7 @@ public PageBuild build(@PathParam("name") String name, @PathParam("buildId") Lon Project p = access.visible(name); Build b = Build.findById(buildId); if (b == null || !p.id.equals(b.projectId)) throw new NotFoundException(); - return pageBuild(b, access.canSeePrivate(p), true); + return pageBuild(b, access.canPin(p), true); } /** The workflow file is sent for a single build only, as it can be up to 64 KiB. */ diff --git a/src/backend/main/java/ai/mnemosyne/cisys/ProjectResource.java b/src/backend/main/java/ai/mnemosyne/cisys/ProjectResource.java index 0ad8004..5c03273 100644 --- a/src/backend/main/java/ai/mnemosyne/cisys/ProjectResource.java +++ b/src/backend/main/java/ai/mnemosyne/cisys/ProjectResource.java @@ -22,6 +22,7 @@ import java.util.Map; import java.util.Locale; import java.util.regex.Pattern; +import java.time.Instant; import jakarta.persistence.PersistenceException; @Path("/api") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) @@ -29,6 +30,7 @@ public class ProjectResource { @Inject SecurityIdentity identity; @Inject RepositoryUrlValidator repositoryUrls; + @Inject ProjectAccess access; private JsonWebToken token() { if (identity.getPrincipal() instanceof JsonWebToken jwt) return jwt; throw new NotAuthorizedException("Bearer"); @@ -51,16 +53,16 @@ private ProjectResponse response(Project p) { return new ProjectResponse(p.id, p.name, p.repositoryUrl, p.privateRepository, subject().equals(p.ownerSubject), identity.hasRole("admin") ? p.ownerSubject : null); } + @GET @Path("/session") public Map session() { requireCisysRole(); return Map.of("username", token().getClaim("preferred_username") == null ? subject() : token().getClaim("preferred_username"), "subject", subject(), "roles", identity.getRoles()); } + @GET @Path("/projects") public List list() { - requireCisysRole(); - List projects = identity.hasRole("admin") ? Project.listAll() - : Project.list("privateRepository = false or ownerSubject = ?1", subject()); - return projects.stream().map(this::response).toList(); + return access.listVisible().stream().map(this::response).toList(); } + @POST @Path("/projects") @Transactional public Response create(CreateProject input) { if (!(identity.hasRole("lead") || identity.hasRole("admin"))) throw new ForbiddenException(); if (input == null || input.name() == null || input.name().isBlank() || input.name().length() > 100 || input.repositoryUrl() == null || input.repositoryUrl().length() > 2048) throw new BadRequestException("Name and repository URL are required"); @@ -78,18 +80,18 @@ private ProjectResponse response(Project p) { Project p = new Project(); p.name = name; p.repositoryUrl = url; p.privateRepository = input.privateRepository(); p.ownerSubject = subject(); try { p.persistAndFlush(); } catch (PersistenceException e) { throw conflict("A project with this name or repository URL already exists"); } + ProjectMembership owner = new ProjectMembership(); + owner.projectId = p.id; + owner.subject = subject(); + owner.role = ProjectMembership.MembershipRole.OWNER; + owner.createdAt = Instant.now(); + owner.persist(); return Response.status(201).entity(response(p)).build(); } /** Deletes a project and its builds; the database unlinks their logs. Admins, or the lead who owns it. */ @DELETE @Path("/projects/{id}") @Transactional public Response delete(@PathParam("id") Long id) { - if (!(identity.hasRole("lead") || identity.hasRole("admin"))) throw new ForbiddenException(); BuildRunner.lockQueue(); - Project p = Project.findById(id); - boolean admin = identity.hasRole("admin"); - boolean owner = p != null && subject().equals(p.ownerSubject); - // Private projects the caller cannot see answer 404, as everywhere else. - if (p == null || (p.privateRepository && !admin && !owner)) throw new NotFoundException(); - if (!admin && !owner) throw new ForbiddenException(); + Project p = access.deletable(id); if (Build.count("projectId = ?1 and status in ('RUNNING', 'QUEUED')", id) > 0) throw conflict("A build is queued or running. Delete the project when its builds finish."); Build.delete("projectId", id); p.delete(); diff --git a/src/backend/main/resources/db/migration/R__schema.sql b/src/backend/main/resources/db/migration/R__schema.sql index 30c8531..ebb8d08 100644 --- a/src/backend/main/resources/db/migration/R__schema.sql +++ b/src/backend/main/resources/db/migration/R__schema.sql @@ -98,3 +98,21 @@ ALTER TABLE build ADD COLUMN IF NOT EXISTS workflow character varying(65536); -- Null uses the configured queue limits. ALTER TABLE settings ADD COLUMN IF NOT EXISTS maxconcurrentbuilds integer; ALTER TABLE settings ADD COLUMN IF NOT EXISTS maxqueuedbuilds integer; + +-- Project membership: one row per user per project; at most one OWNER row per project. +CREATE TABLE IF NOT EXISTS projectmembership ( + id bigint NOT NULL, + projectid bigint NOT NULL, + subject character varying(255) NOT NULL, + role character varying(16) NOT NULL, + createdat timestamp(6) with time zone NOT NULL, + CONSTRAINT projectmembership_pkey PRIMARY KEY (id), + CONSTRAINT fk_membership_project FOREIGN KEY (projectid) REFERENCES project(id) ON DELETE CASCADE, + CONSTRAINT uk_membership_project_subject UNIQUE (projectid, subject) +); + +CREATE SEQUENCE IF NOT EXISTS projectmembership_seq START WITH 1 INCREMENT BY 50; + +CREATE UNIQUE INDEX IF NOT EXISTS one_owner_per_project ON projectmembership (projectid) WHERE role = 'OWNER'; + +CREATE INDEX IF NOT EXISTS membership_by_subject ON projectmembership (subject); diff --git a/src/backend/test/java/ai/mnemosyne/cisys/BranchResourceTest.java b/src/backend/test/java/ai/mnemosyne/cisys/BranchResourceTest.java index 0edad9f..55cf277 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/BranchResourceTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/BranchResourceTest.java @@ -44,6 +44,12 @@ private Long project(boolean priv, String repositoryUrl) { p.ownerSubject = "owner-id"; p.privateRepository = priv; p.persist(); + ProjectMembership m = new ProjectMembership(); + m.projectId = p.id; + m.subject = "owner-id"; + m.role = ProjectMembership.MembershipRole.OWNER; + m.createdAt = java.time.Instant.now(); + m.persist(); return p.id; }); } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/BuildResourceTest.java b/src/backend/test/java/ai/mnemosyne/cisys/BuildResourceTest.java index 0299ae6..fdc5bf6 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/BuildResourceTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/BuildResourceTest.java @@ -25,7 +25,14 @@ @Inject BuildRunner runner; @Inject BuildLogs logs; - private Long project(boolean priv){return QuarkusTransaction.requiringNew().call(()->{Project p=new Project();String n=java.util.UUID.randomUUID().toString();p.name="example-"+n;p.repositoryUrl="https://github.com/octocat/Hello-World-"+n+".git";p.ownerSubject="owner-id";p.privateRepository=priv;p.persist();return p.id;});} + private Long project(boolean priv){return QuarkusTransaction.requiringNew().call(()->{Project p=new Project();String n=java.util.UUID.randomUUID().toString();p.name="example-"+n;p.repositoryUrl="https://github.com/octocat/Hello-World-"+n+".git";p.ownerSubject="owner-id";p.privateRepository=priv;p.persist(); ProjectMembership m = new ProjectMembership(); + m.projectId = p.id; + m.subject = "owner-id"; + m.role = ProjectMembership.MembershipRole.OWNER; + m.createdAt = java.time.Instant.now(); + m.persist(); +return p.id;});} + private static void membership(Long projectId, String subject, ProjectMembership.MembershipRole role){QuarkusTransaction.requiringNew().run(()->{ProjectMembership m = new ProjectMembership();m.projectId = projectId;m.subject = subject;m.role = role;m.createdAt = java.time.Instant.now();m.persist();});} private static void allow(String image){QuarkusTransaction.requiringNew().run(()->{if(AllowedImage.findById(image)==null){AllowedImage i=new AllowedImage();i.name=image;i.persist();}});} private Long build(Long project,String status,String... logParts){Long id=QuarkusTransaction.requiringNew().call(()->{Build b=new Build();b.projectId=project;b.requestedBy="owner-id";b.status=Build.Status.valueOf(status);b.branch="main";b.image="alpine:3.23";b.exitCode="PASSED".equals(status)?0:null;b.startedAt=java.time.Instant.now();b.persist();return b.id;});if(logParts.length>0)try(var w=logs.open(id)){for(String part:logParts){byte[] bytes=part.getBytes(java.nio.charset.StandardCharsets.UTF_8);w.append(bytes,0,bytes.length);}}return id;} static final String REQUEST="{\"branch\":\"main\"}"; @@ -132,4 +139,16 @@ void buildHistoryHidesCredentialReferenceAndSupportsLegacyBuilds() { @Test @TestSecurity(user="member",roles="member") @JwtSecurity(claims=@Claim(key="sub",value="other-id")) void memberDoesNotSeeRequester(){requesterVisible(false);} @Test @TestSecurity(user="lead",roles="lead") @JwtSecurity(claims=@Claim(key="sub",value="owner-id")) void leadDoesNotSeeRequester(){requesterVisible(false);} @Test @TestSecurity(user="admin",roles="admin") @JwtSecurity(claims=@Claim(key="sub",value="admin-id")) void adminSeesRequester(){requesterVisible(true);} + + @Test @TestSecurity(user="lead",roles="lead") @JwtSecurity(claims=@Claim(key="sub",value="other-id")) void leadNotMemberCannotBuildPublicProject(){Long id=project(false);allow("alpine:3.23");given().contentType("application/json").body(REQUEST).post("/api/projects/"+id+"/builds").then().statusCode(403);} + @Test @TestSecurity(user="lead",roles="lead") @JwtSecurity(claims=@Claim(key="sub",value="other-id")) void leadNotMemberCannotBuildPrivateProject(){Long id=project(true);allow("alpine:3.23");given().contentType("application/json").body(REQUEST).post("/api/projects/"+id+"/builds").then().statusCode(404);} + @Test @TestSecurity(user="lead",roles="lead") @JwtSecurity(claims=@Claim(key="sub",value="lead2-id")) void leadMemberCanBuildPrivateProject(){Long id=project(true);membership(id,"lead2-id",ProjectMembership.MembershipRole.MEMBER);allow("alpine:3.23");try{given().contentType("application/json").body(REQUEST).post("/api/projects/"+id+"/builds").then().statusCode(200).body("projectId",equalTo(id.intValue()));}finally{QuarkusTransaction.requiringNew().run(()->Build.delete("projectId = ?1",id));}} + @Test @TestSecurity(user="member",roles="member") @JwtSecurity(claims=@Claim(key="sub",value="owner-id")) void memberEvenAsOwnerCannotBuild(){Long id=project(false);allow("alpine:3.23");given().contentType("application/json").body(REQUEST).post("/api/projects/"+id+"/builds").then().statusCode(403);} + @Test @TestSecurity(user="admin",roles="admin") @JwtSecurity(claims=@Claim(key="sub",value="admin-id")) void adminCanBuildPublicProjectWithoutMembership(){Long id=project(false);allow("alpine:3.23");try{given().contentType("application/json").body(REQUEST).post("/api/projects/"+id+"/builds").then().statusCode(200);}finally{QuarkusTransaction.requiringNew().run(()->Build.delete("projectId = ?1",id));}} + @Test @TestSecurity(user="member",roles="member") @JwtSecurity(claims=@Claim(key="sub",value="member-x")) void memberSeesPrivateBuildsWhenProjectMember(){Long id=project(true);membership(id,"member-x",ProjectMembership.MembershipRole.MEMBER);given().get("/api/projects/"+id+"/builds").then().statusCode(200);} + @Test @TestSecurity(user="member",roles="member") @JwtSecurity(claims=@Claim(key="sub",value="member-pin")) void memberWithMembershipCannotPin(){Long id=project(false);membership(id,"member-pin",ProjectMembership.MembershipRole.MEMBER);Long b=build(id,"PASSED");given().contentType("application/json").put("/api/projects/"+id+"/builds/"+b+"/pin").then().statusCode(403);} + @Test @TestSecurity(user="lead",roles="lead") @JwtSecurity(claims=@Claim(key="sub",value="lead3-id")) void leadMemberCanBuildPublicProject(){Long id=project(false);membership(id,"lead3-id",ProjectMembership.MembershipRole.MEMBER);allow("alpine:3.23");try{given().contentType("application/json").body(REQUEST).post("/api/projects/"+id+"/builds").then().statusCode(200).body("projectId",equalTo(id.intValue()));assertEquals(1L,(long)QuarkusTransaction.requiringNew().call(()->Build.count("projectId = ?1",id)));}finally{QuarkusTransaction.requiringNew().run(()->Build.delete("projectId = ?1",id));}} + @Test @TestSecurity(user="admin",roles="admin") @JwtSecurity(claims=@Claim(key="sub",value="admin-id")) void adminCanBuildPrivateProjectWithoutMembership(){Long id=project(true);allow("alpine:3.23");try{given().contentType("application/json").body(REQUEST).post("/api/projects/"+id+"/builds").then().statusCode(200).body("projectId",equalTo(id.intValue()));assertEquals(1L,(long)QuarkusTransaction.requiringNew().call(()->Build.count("projectId = ?1",id)));}finally{QuarkusTransaction.requiringNew().run(()->Build.delete("projectId = ?1",id));}} + @Test @TestSecurity(user="lead",roles="lead") @JwtSecurity(claims=@Claim(key="sub",value="lead-pin")) void leadMemberNotOwnerCannotPin(){Long id=project(false);membership(id,"lead-pin",ProjectMembership.MembershipRole.MEMBER);Long b=build(id,"PASSED");given().contentType("application/json").put("/api/projects/"+id+"/builds/"+b+"/pin").then().statusCode(403);assertFalse(pinned(b));} + @Test @TestSecurity(user="member",roles="member") @JwtSecurity(claims=@Claim(key="sub",value="owner-id")) void memberEvenAsOwnerCannotPin(){Long id=project(false);Long b=build(id,"PASSED");given().contentType("application/json").put("/api/projects/"+id+"/builds/"+b+"/pin").then().statusCode(403);assertFalse(pinned(b));} } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/KeycloakUsersTest.java b/src/backend/test/java/ai/mnemosyne/cisys/KeycloakUsersTest.java new file mode 100644 index 0000000..9b5f180 --- /dev/null +++ b/src/backend/test/java/ai/mnemosyne/cisys/KeycloakUsersTest.java @@ -0,0 +1,76 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THIS PROGRAM CONSTITUTING RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne.cisys; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class KeycloakUsersTest { + private HttpServer keycloak; + private int tokenStatus = 200; + private String usersResponse = "[{\"id\":\"sub-123\",\"username\":\"ahmed\"}]"; + + @BeforeEach void start() throws IOException { + keycloak = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + keycloak.createContext("/realms/cisys/protocol/openid-connect/token", exchange -> { + byte[] body = "{\"access_token\":\"abc\",\"expires_in\":60}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(tokenStatus, body.length); + try (OutputStream out = exchange.getResponseBody()) { out.write(body); } + }); + keycloak.createContext("/admin/realms/cisys/users", exchange -> { + String path = exchange.getHttpContext().getPath(); + String body = exchange.getRequestURI().toString().contains("/users/sub-123") ? "{\"username\":\"ahmed\"}" : usersResponse; + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream out = exchange.getResponseBody()) { out.write(bytes); } + }); + keycloak.start(); + } + + @AfterEach void stop() { + keycloak.stop(0); + } + + private KeycloakUsers users() { + KeycloakUsers users = new KeycloakUsers(); + users.keycloakUrl = "http://127.0.0.1:" + keycloak.getAddress().getPort(); + users.realm = "cisys"; + users.clientId = "cisys-backend"; + users.clientSecret = "secret"; + return users; + } + + @Test void resolvesUsernameToSubject() { + assertEquals("sub-123", users().subjectOf("ahmed")); + } + + @Test void unknownUsernameResolvesToNull() { + usersResponse = "[]"; + assertNull(users().subjectOf("ghost")); + } + + @Test void resolvesSubjectToUsername() { + assertEquals("ahmed", users().usernameOf("sub-123")); + } + + @Test void refusedClientFailsLoudly() { + tokenStatus = 401; + assertThrows(IllegalStateException.class, () -> users().subjectOf("ahmed")); + } +} diff --git a/src/backend/test/java/ai/mnemosyne/cisys/MembershipTestProfile.java b/src/backend/test/java/ai/mnemosyne/cisys/MembershipTestProfile.java new file mode 100644 index 0000000..749e146 --- /dev/null +++ b/src/backend/test/java/ai/mnemosyne/cisys/MembershipTestProfile.java @@ -0,0 +1,19 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THIS PROGRAM CONSTITUTING RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne.cisys; + +import io.quarkus.test.junit.QuarkusTestProfile; +import java.util.Set; + +public class MembershipTestProfile implements QuarkusTestProfile { + @Override + public Set> getEnabledAlternatives() { + return Set.of(TestKeycloakUsers.class); + } +} diff --git a/src/backend/test/java/ai/mnemosyne/cisys/ProjectBranchResourceTest.java b/src/backend/test/java/ai/mnemosyne/cisys/ProjectBranchResourceTest.java index ba90f7f..71022ac 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/ProjectBranchResourceTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/ProjectBranchResourceTest.java @@ -47,6 +47,12 @@ private String project(boolean priv, String repositoryUrl) { p.ownerSubject = "owner-id"; p.privateRepository = priv; p.persist(); + ProjectMembership m = new ProjectMembership(); + m.projectId = p.id; + m.subject = "owner-id"; + m.role = ProjectMembership.MembershipRole.OWNER; + m.createdAt = java.time.Instant.now(); + m.persist(); return name; }); } diff --git a/src/backend/test/java/ai/mnemosyne/cisys/ProjectMembershipResourceTest.java b/src/backend/test/java/ai/mnemosyne/cisys/ProjectMembershipResourceTest.java new file mode 100644 index 0000000..6fbe107 --- /dev/null +++ b/src/backend/test/java/ai/mnemosyne/cisys/ProjectMembershipResourceTest.java @@ -0,0 +1,187 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THIS PROGRAM CONSTITUTING RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne.cisys; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.quarkus.narayana.jta.QuarkusTransaction; +import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; +import io.quarkus.test.security.jwt.Claim; +import io.quarkus.test.security.jwt.JwtSecurity; +import io.restassured.http.ContentType; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@QuarkusTest @TestProfile(MembershipTestProfile.class) +class ProjectMembershipResourceTest { + + @BeforeEach + void users() { + TestKeycloakUsers.reset(); + TestKeycloakUsers.user("owner", "owner-id"); + TestKeycloakUsers.user("member", "member-id"); + TestKeycloakUsers.user("admin", "admin-id"); + TestKeycloakUsers.user("other", "other-id"); + } + + private Long project(boolean priv) { + return QuarkusTransaction.requiringNew().call(() -> { + Project p = new Project(); + String n = UUID.randomUUID().toString(); + p.name = "members-" + n; + p.repositoryUrl = "https://github.com/octocat/members-" + n + ".git"; + p.ownerSubject = "owner-id"; + p.privateRepository = priv; + p.persist(); + membership(p.id, "owner-id", ProjectMembership.MembershipRole.OWNER); + return p.id; + }); + } + + private static void membership(Long projectId, String subject, ProjectMembership.MembershipRole role) { + ProjectMembership m = new ProjectMembership(); + m.projectId = projectId; + m.subject = subject; + m.role = role; + m.createdAt = java.time.Instant.now(); + m.persist(); + } + + @Test @TestSecurity(user = "lead", roles = "lead") @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void ownerSeesMembers() { + Long id = project(true); + given().get("/api/projects/" + id + "/members").then().statusCode(200).body("$", hasSize(1)) + .body("[0].username", equalTo("owner"), "[0].role", equalTo("OWNER")); + } + + @Test @TestSecurity(user = "member", roles = "member") @JwtSecurity(claims = @Claim(key = "sub", value = "member-id")) + void projectMemberSeesMembers() { + Long id = project(true); + QuarkusTransaction.requiringNew().run(() -> membership(id, "member-id", ProjectMembership.MembershipRole.MEMBER)); + given().get("/api/projects/" + id + "/members").then().statusCode(200).body("$", hasSize(2)); + } + + @Test @TestSecurity(user = "admin", roles = "admin") @JwtSecurity(claims = @Claim(key = "sub", value = "admin-id")) + void adminSeesMembers() { + Long id = project(true); + given().get("/api/projects/" + id + "/members").then().statusCode(200).body("$", hasSize(1)); + } + + @Test @TestSecurity(user = "lead", roles = "lead") @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void outsiderGets404OnPrivateProject() { + Long id = project(true); + given().get("/api/projects/" + id + "/members").then().statusCode(404); + } + + @Test @TestSecurity(user = "lead", roles = "lead") @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) + void outsiderGets403OnPublicProject() { + Long id = project(false); + given().get("/api/projects/" + id + "/members").then().statusCode(403); + } + + @Test @TestSecurity(user = "lead", roles = "lead") @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void ownerAddsMember() { + Long id = project(true); + given().contentType(ContentType.JSON).body("{\"username\":\"member\"}").post("/api/projects/" + id + "/members") + .then().statusCode(201).body("username", equalTo("member"), "role", equalTo("MEMBER")); + given().get("/api/projects/" + id + "/members").then().statusCode(200).body("$", hasSize(2)); + } + + @Test @TestSecurity(user = "admin", roles = "admin") @JwtSecurity(claims = @Claim(key = "sub", value = "admin-id")) + void adminAddsMember() { + Long id = project(false); + given().contentType(ContentType.JSON).body("{\"username\":\"member\"}").post("/api/projects/" + id + "/members") + .then().statusCode(201); + } + + @Test @TestSecurity(user = "member", roles = "member") @JwtSecurity(claims = @Claim(key = "sub", value = "member-id")) + void memberCannotAdd() { + Long id = project(false); + QuarkusTransaction.requiringNew().run(() -> membership(id, "member-id", ProjectMembership.MembershipRole.MEMBER)); + given().contentType(ContentType.JSON).body("{\"username\":\"other\"}").post("/api/projects/" + id + "/members").then().statusCode(403); + } + + @Test @TestSecurity(user = "lead", roles = "lead") @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void unknownUsernameIs404() { + Long id = project(false); + given().contentType(ContentType.JSON).body("{\"username\":\"ghost\"}").post("/api/projects/" + id + "/members").then().statusCode(404); + } + + @Test @TestSecurity(user = "lead", roles = "lead") @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void existingMemberIs409() { + Long id = project(false); + given().contentType(ContentType.JSON).body("{\"username\":\"member\"}").post("/api/projects/" + id + "/members").then().statusCode(201); + given().contentType(ContentType.JSON).body("{\"username\":\"member\"}").post("/api/projects/" + id + "/members").then().statusCode(409); + } + + @Test @TestSecurity(user = "admin", roles = "admin") @JwtSecurity(claims = @Claim(key = "sub", value = "admin-id")) + void adminRemovesMember() { + Long id = project(false); + QuarkusTransaction.requiringNew().run(() -> membership(id, "member-id", ProjectMembership.MembershipRole.MEMBER)); + given().delete("/api/projects/" + id + "/members/member").then().statusCode(204); + } + + @Test @TestSecurity(user = "admin", roles = "admin") @JwtSecurity(claims = @Claim(key = "sub", value = "admin-id")) + void adminCannotRemoveProjectOwner() { + Long id = project(false); + given().delete("/api/projects/" + id + "/members/owner").then().statusCode(409); + } + + @Test @TestSecurity(user = "member", roles = "member") @JwtSecurity(claims = @Claim(key = "sub", value = "member-id")) + void memberCannotRemove() { + Long id = project(false); + QuarkusTransaction.requiringNew().run(() -> membership(id, "member-id", ProjectMembership.MembershipRole.MEMBER)); + given().delete("/api/projects/" + id + "/members/owner").then().statusCode(403); + } + + @Test @TestSecurity(user = "member", roles = "member") @JwtSecurity(claims = @Claim(key = "sub", value = "member-id")) + void revokedMembershipLosesAccess() { + Long id = project(true); + QuarkusTransaction.requiringNew().run(() -> membership(id, "member-id", ProjectMembership.MembershipRole.MEMBER)); + given().get("/api/projects/" + id + "/builds").then().statusCode(200); + QuarkusTransaction.requiringNew().run(() -> ProjectMembership.delete("projectId = ?1 and subject = ?2", id, "member-id")); + given().get("/api/projects/" + id + "/builds").then().statusCode(404); + } + + @Test @TestSecurity(user = "lead", roles = "lead") @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void ownerCannotBeRemoved() { + Long id = project(false); + given().delete("/api/projects/" + id + "/members/owner").then().statusCode(409); + } + + @Test @TestSecurity(user = "lead", roles = "lead") @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void ownerRemovesMember() { + Long id = project(false); + QuarkusTransaction.requiringNew().run(() -> membership(id, "member-id", ProjectMembership.MembershipRole.MEMBER)); + given().delete("/api/projects/" + id + "/members/member").then().statusCode(204); + assertEquals(1, QuarkusTransaction.requiringNew().call(() -> + ProjectMembership.count("projectId = ?1", id))); + } + + @Test @TestSecurity(user = "lead", roles = "lead") @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void removingNonMemberIs404() { + Long id = project(false); + given().delete("/api/projects/" + id + "/members/member").then().statusCode(404); + } + + @Test @TestSecurity(user = "lead", roles = "lead") @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void keycloakFailureIsGeneric502() { + Long id = project(false); + TestKeycloakUsers.unavailable(); + given().contentType(ContentType.JSON).body("{\"username\":\"member\"}").post("/api/projects/" + id + "/members") + .then().statusCode(502).body("error", equalTo("Keycloak is unavailable; try again later")); + given().delete("/api/projects/" + id + "/members/member") + .then().statusCode(502).body("error", equalTo("Keycloak is unavailable; try again later")); + } +} diff --git a/src/backend/test/java/ai/mnemosyne/cisys/ProjectPageResourceTest.java b/src/backend/test/java/ai/mnemosyne/cisys/ProjectPageResourceTest.java index 2944dd5..c076c68 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/ProjectPageResourceTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/ProjectPageResourceTest.java @@ -45,6 +45,12 @@ private Seeded seed(boolean priv, String logText) { p.ownerSubject = "owner-id"; p.privateRepository = priv; p.persist(); + ProjectMembership m = new ProjectMembership(); + m.projectId = p.id; + m.subject = "owner-id"; + m.role = ProjectMembership.MembershipRole.OWNER; + m.createdAt = java.time.Instant.now(); + m.persist(); Build b = new Build(); b.projectId = p.id; b.status=Build.Status.PASSED; @@ -264,6 +270,52 @@ void unknownProjectIsNotFound() { given().get("/api/project-pages/does-not-exist-" + UUID.randomUUID()).then().statusCode(404); } + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = @Claim(key = "sub", value = "member-id")) + void memberSeesPrivateProjectPage() { + String name = project(true); + QuarkusTransaction.requiringNew().run(() -> { + Project p = Project.find("name", name).firstResult(); + ProjectMembership m = new ProjectMembership(); + m.projectId = p.id; + m.subject = "member-id"; + m.role = ProjectMembership.MembershipRole.MEMBER; + m.createdAt = java.time.Instant.now(); + m.persist(); + }); + given().get("/api/project-pages/" + name).then().statusCode(200); + } + + @Test + @TestSecurity(user = "lead", roles = "lead") + @JwtSecurity(claims = @Claim(key = "sub", value = "lead-member-id")) + void projectMemberGetsNoPinButton() { + Seeded s = seed(true); + QuarkusTransaction.requiringNew().run(() -> { + Project p = Project.find("name", s.name()).firstResult(); + ProjectMembership m = new ProjectMembership(); + m.projectId = p.id; + m.subject = "lead-member-id"; + m.role = ProjectMembership.MembershipRole.MEMBER; + m.createdAt = java.time.Instant.now(); + m.persist(); + }); + given().get("/api/project-pages/" + s.name()).then().statusCode(200) + .body("builds", hasSize(1), "builds[0].canPin", equalTo(false)); + given().get("/api/project-pages/" + s.name() + "/builds/" + s.buildId()).then().statusCode(200) + .body("canPin", equalTo(false)); + } + + @Test + @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = @Claim(key = "sub", value = "owner-id")) + void memberRoleOwnerGetsNoPinButton() { + Seeded s = seed(false); + given().get("/api/project-pages/" + s.name() + "/builds/" + s.buildId()).then().statusCode(200) + .body("canPin", equalTo(false)); + } + @Test @TestSecurity(user = "member", roles = "member") @JwtSecurity(claims = @Claim(key = "sub", value = "other-id")) diff --git a/src/backend/test/java/ai/mnemosyne/cisys/ProjectResourceTest.java b/src/backend/test/java/ai/mnemosyne/cisys/ProjectResourceTest.java index 6d7a547..d9b8309 100644 --- a/src/backend/test/java/ai/mnemosyne/cisys/ProjectResourceTest.java +++ b/src/backend/test/java/ai/mnemosyne/cisys/ProjectResourceTest.java @@ -36,6 +36,11 @@ void leadCreatesPrivateProjectWithSubjectOwner() { .when().post("/api/projects").then().statusCode(201) .body("owned", equalTo(true), "privateRepository", equalTo(true), "$", not(hasKey("ownerSubject"))); assertEquals("lead-id", QuarkusTransaction.requiringNew().call(() -> Project.find("name", "demo").firstResult().ownerSubject)); + assertEquals(1L, (long) QuarkusTransaction.requiringNew().call(() -> { + Project created = Project.find("name", "demo").firstResult(); + return ProjectMembership.count("projectId = ?1 and subject = ?2 and role = ?3", + created.id, "lead-id", ProjectMembership.MembershipRole.OWNER); + })); } @Test @TestSecurity(user = "member", roles = "member") @JwtSecurity(claims = {@Claim(key = "sub", value = "member-id")}) @@ -44,6 +49,21 @@ void memberListHidesOwnerSubject() { given().when().get("/api/projects").then().statusCode(200) .body(inList(id) + ".owned", equalTo(false), inList(id), not(hasKey("ownerSubject")), "ownerSubject", everyItem(nullValue())); } + @Test @TestSecurity(user = "member", roles = "member") + @JwtSecurity(claims = {@Claim(key = "sub", value = "member-id")}) + void memberSeesPrivateProjectTheyBelongTo() { + Long id = project("other-id", true); + QuarkusTransaction.requiringNew().run(() -> { + ProjectMembership m = new ProjectMembership(); + m.projectId = id; + m.subject = "member-id"; + m.role = ProjectMembership.MembershipRole.MEMBER; + m.createdAt = java.time.Instant.now(); + m.persist(); + }); + given().when().get("/api/projects").then().statusCode(200).body("id", hasItem(id.intValue())); + } + @Test @TestSecurity(user = "lead", roles = "lead") @JwtSecurity(claims = {@Claim(key = "sub", value = "lead-id")}) void leadListMarksOwnProjects() { @@ -138,7 +158,14 @@ private static Long project(String owner, boolean priv) { return QuarkusTransaction.requiringNew().call(() -> { Project p = new Project(); String n = java.util.UUID.randomUUID().toString(); p.name = "delete-" + n; p.repositoryUrl = "https://example.com/delete-" + n; p.ownerSubject = owner; p.privateRepository = priv; - p.persist(); return p.id; + p.persist(); + ProjectMembership m = new ProjectMembership(); + m.projectId = p.id; + m.subject = owner; + m.role = ProjectMembership.MembershipRole.OWNER; + m.createdAt = java.time.Instant.now(); + m.persist(); + return p.id; }); } private static Long build(Long project, String status) { diff --git a/src/backend/test/java/ai/mnemosyne/cisys/TestKeycloakUsers.java b/src/backend/test/java/ai/mnemosyne/cisys/TestKeycloakUsers.java new file mode 100644 index 0000000..ee090f0 --- /dev/null +++ b/src/backend/test/java/ai/mnemosyne/cisys/TestKeycloakUsers.java @@ -0,0 +1,38 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THIS PROGRAM CONSTITUTING RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne.cisys; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Alternative; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** Replaces the real Keycloak admin calls in membership tests. */ +@Alternative +@ApplicationScoped +public class TestKeycloakUsers extends KeycloakUsers { + private static final Map SUBJECTS = new ConcurrentHashMap<>(); + private static volatile boolean unavailable; + + static void reset() { SUBJECTS.clear(); unavailable = false; } + static void unavailable() { unavailable = true; } + static void user(String username, String subject) { SUBJECTS.put(username, subject); } + + @Override + public String subjectOf(String username) { + if (unavailable) throw new IllegalStateException("Keycloak answered HTTP 503 for /admin/realms/cisys/users?username=" + username); + return SUBJECTS.get(username); + } + + @Override + public String usernameOf(String subject) { + for (Map.Entry e : SUBJECTS.entrySet()) if (e.getValue().equals(subject)) return e.getKey(); + return "unknown"; + } +}