gitshark

Clone repository

git clone https://gitshark.de/git/workaround/Gitshark.git
git clone git@gitshark.de:workaround/Gitshark.git

← Commits

✨ (collaborators): Add repository collaborators with read/write access

b09491e57f833bebddbe1cee878259a85211b70e · Michael Hainz · 2026-07-09T09:31:51Z

Changes

23 files changed, +836 -25

MODIFY README.md +17 -9
diff --git a/README.md b/README.md
index 2f25252..df39d39 100644
--- a/README.md
+++ b/README.md
@@ -14,18 +14,25 @@
14 14 - push and private read authenticate with **personal access tokens** (HTTP Basic password)
15 15 - Clone/fetch/push over `ssh://git@<host>:2222/<owner>/<repo>.git`
16 16 - public-key authentication only; keys managed per user in the UI
17 -- Web UI: an auth-aware header nav (a "Log in" button for visitors; Profile, SSH keys, Access tokens, and Logout links only for signed-in users), landing page with login CTA for visitors (`/`), repository list for authenticated users (`/`), public repository browse at `/explore`, file/tree browser with self-hosted syntax highlighting (extension-based language detection, falls back to plain text for unknown extensions and binary files), a rendered README (commonmark-java, XSS-safe) shown below the file list on the repository overview page, commit log (paginated), branches, tags (own dedicated page, separate from branches), one-time handle selection (`/onboarding`), profile settings (`/settings/profile`). Every repository sub-page shows a persistent left sidebar with repo identity, a Clone button opening the clone dialog, a pin toggle, and section navigation (Code, Commits, Branches, Tags, Issues, Merge requests) with per-section counts and active-section highlighting, and the clone panel has copy-to-clipboard buttons for the HTTP and SSH `git clone` commands. Keyboard shortcuts are an optional, progressive enhancement (`?` opens a help overlay, `Escape` closes it, `g h` goes home) — every page works fully without JavaScript
18 -- Per-repository issues: title, optional description, per-repo sequential number (`#1`, `#2`, …), and author; created and managed by the repo owner, readable by anyone who can read the repo, via a dedicated "New issue" page
17 +- Web UI: an auth-aware header nav (a "Log in" button for visitors; Profile, SSH keys, Access tokens, and Logout links only for signed-in users), landing page with login CTA for visitors (`/`), repository list for authenticated users (`/`), public repository browse at `/explore`, file/tree browser with self-hosted syntax highlighting (extension-based language detection, falls back to plain text for unknown extensions and binary files), a rendered README (commonmark-java, XSS-safe) shown below the file list on the repository overview page, commit log (paginated), branches, tags (own dedicated page, separate from branches), one-time handle selection (`/onboarding`), profile settings (`/settings/profile`). Every repository sub-page shows a persistent left sidebar with repo identity, a Clone button opening the clone dialog, a pin toggle, and section navigation (Code, Commits, Branches, Tags, Issues, Merge requests, plus Collaborators for the owner) with per-section counts and active-section highlighting, and the clone panel has copy-to-clipboard buttons for the HTTP and SSH `git clone` commands. Keyboard shortcuts are an optional, progressive enhancement (`?` opens a help overlay, `Escape` closes it, `g h` goes home) — every page works fully without JavaScript
18 +- Per-repository issues: title, optional description, per-repo sequential number (`#1`, `#2`, …), and author; created and managed by the repo owner and collaborators, readable by anyone who can read the repo, via a dedicated "New issue" page
19 19 - Issues move through a fixed lifecycle (Planned → In development → Done); the repo navigation shows the open (Planned + In development) issue count, and Done issues collapse into an "Archive" section on the issues page
20 20 - Issues auto-close from pushed commit messages, GitHub-style (`close(s|d)`/`fix(es|ed)`/`resolve(s|d)` + `#<number>`, e.g. `fixes #12`), over both HTTP and SSH pushes
21 -- Per-repository merge requests: source → target branch within one repo, with a title, optional description, a per-repo sequential number displayed bang-prefixed (`!1`, `!2`, …, distinct from issues' `#`), and an author; created and managed by the repo owner, readable by anyone who can read the repo, via a dedicated "New merge request" page where the owner picks source and target from the repo's branches
21 +- Per-repository merge requests: source → target branch within one repo, with a title, optional description, a per-repo sequential number displayed bang-prefixed (`!1`, `!2`, …, distinct from issues' `#`), and an author; created and managed by the repo owner and collaborators, readable by anyone who can read the repo, via a dedicated "New merge request" page where the author picks source and target from the repo's branches
22 22 - Merge requests move through the lifecycle Open → Merged / Closed; the repo navigation and left sidebar show the open merge-request count, and merged/closed ones collapse into an "Archive" section on the list page (same pattern as issues)
23 23 - The merge request detail page renders the live diff of the source branch relative to the merge base with the target (three-dot diff), file by file with per-line add/delete coloring and a changed-files / +additions / −deletions summary — always computed live from git, never duplicated into the database
24 -- The owner can Merge or Close an open merge request from the detail page; merging runs entirely in-core against the bare repository (no working tree), fast-forwarding when possible or else recording a two-parent merge commit authored by the acting user and advancing the target branch ref. An automatic merge that would conflict is rejected; a source branch already contained in the target is treated as already merged
25 -- Line-level review comments on a merge request's diff: any authenticated user who can read the repository can comment on a specific diff line (added, deleted, or context) from the merge-request detail page; comments render inline beneath the line they anchor to. A comment can be deleted by its author or by the repository owner. Comments are anchored to a file plus the diff line's old/new line numbers and must land on a line that's part of the current diff. Hovering a commentable line reveals a comment icon on the right; clicking it opens the form inline — a progressive-enhancement disclosure that works without JavaScript
24 +- The owner or a collaborator can Merge or Close an open merge request from the detail page; merging runs entirely in-core against the bare repository (no working tree), fast-forwarding when possible or else recording a two-parent merge commit authored by the acting user and advancing the target branch ref. An automatic merge that would conflict is rejected; a source branch already contained in the target is treated as already merged
25 +- Line-level review comments on a merge request's diff: any authenticated user who can read the repository can comment on a specific diff line (added, deleted, or context) from the merge-request detail page; comments render inline beneath the line they anchor to. A comment can be deleted by its author, the repository owner, or a collaborator. Comments are anchored to a file plus the diff line's old/new line numbers and must land on a line that's part of the current diff. Hovering a commentable line reveals a comment icon on the right; clicking it opens the form inline — a progressive-enhancement disclosure that works without JavaScript
26 26 - OIDC login (authorization code flow) via `GET /login`; on first login the user account is created without a username and the browser is redirected to `/onboarding`, where the user picks a URL-safe handle (`^[a-z0-9][a-z0-9-]{0,38}$`, unique). The chosen handle — not the OIDC `preferred_username` claim (which is an SPN form in kanidm and not URL-safe) — is used in all repo, SSH, ActivityPub, and webfinger URLs. The `name` claim becomes an editable display name; both can be changed later at `/settings/profile`. A request filter blocks all app pages until a handle is chosen. Logout is local-session only via `POST /logout` (the kanidm provider advertises no `end_session_endpoint`, so RP-Initiated Logout is disabled)
27 27 - Profile pictures: users can upload a PNG/JPEG/GIF/WebP avatar (≤ 2 MB, content-type and magic bytes both validated) at `/settings/profile`, stored on the filesystem keyed by user UUID and served publicly at `GET /users/{username}/avatar`; shown wherever a local user is rendered (header nav, repo lists, repo sidebar, issue/MR/comment authors) via a reusable Qute avatar tag, removable, and falling back to an initials badge when absent. Git commit authors and remote federation actors are not local users and keep their existing pseudo-avatars
28 -- Single access policy on all paths: owner read/write, public world-readable, private owner-only
28 +- **Collaborators** — the repository owner can grant other local users read+write access
29 + (one flat role) on a per-repository settings page (`…/settings/collaborators`, linked in the
30 + repo sidebar for the owner). Collaborators can read and push — UI, HTTP, and SSH alike — even
31 + on private repositories, and manage issues and merge requests; deleting the repository,
32 + managing mirrors, and managing collaborators stay owner-only. Guides:
33 + [for users](docs/users/collaborators.md), [for admins](docs/admins/collaborators.md)
34 +- Single access policy on all paths: owner read/write, collaborators read/write, public
35 + world-readable, private repositories visible to the owner and collaborators only
29 36 - **JSON REST API** under `/api/v1`, authenticated with the same personal access tokens as
30 37 git-over-HTTP (`Authorization: Bearer <token>`), auto-documented via OpenAPI/Swagger UI (see below)
31 38 - **MCP server** at `/mcp` (Streamable HTTP), exposing the same feature set as the REST API as
@@ -84,8 +91,9 @@
84 91
85 92 - Authenticated with the **same personal access tokens** used for git-over-HTTP, but sent as
86 93 `Authorization: Bearer <token>` (not HTTP Basic)
87 -- Anonymous requests are allowed for public reads only; mutations require a token and repository
88 - ownership (posting a comment only requires read access)
94 +- Anonymous requests are allowed for public reads only; mutations require a token and write
95 + access (owner or collaborator; posting a comment only requires read access, deleting a
96 + repository stays owner-only)
89 97 - Same visibility rules as the UI: private repositories are hidden as `404`, not `403`
90 98
91 99 | Method | Path | Description |
@@ -102,7 +110,7 @@
102 110 | POST | `/api/v1/repos/{owner}/{name}/merge-requests/{number}/merge` | Merge |
103 111 | POST | `/api/v1/repos/{owner}/{name}/merge-requests/{number}/close` | Close |
104 112 | GET, POST | `/api/v1/repos/{owner}/{name}/merge-requests/{number}/comments` | List / add line-level review comments (any reader may comment) |
105 -| DELETE | `/api/v1/repos/{owner}/{name}/merge-requests/{number}/comments/{commentId}` | Delete a comment (author or repo owner) |
113 +| DELETE | `/api/v1/repos/{owner}/{name}/merge-requests/{number}/comments/{commentId}` | Delete a comment (author, repo owner, or collaborator) |
106 114
107 115 ## MCP server
108 116
MODIFY docs/README.md +4 -0
diff --git a/docs/README.md b/docs/README.md
index 28e8794..ff7a3e5 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -15,6 +15,8 @@
15 15 - **[Push mirrors](users/mirrors.md)** — replicate a repository to an external
16 16 remote on every push: HTTPS vs SSH setup, deploy keys, status and
17 17 troubleshooting.
18 +- **[Collaborators](users/collaborators.md)** — grant other users read and
19 + write access to your repository, what collaborators can and cannot do.
18 20
19 21 ### For admins
20 22
@@ -25,6 +27,8 @@
25 27 configuration, allowlist, reverse-proxy requirements, delivery queue, monitoring.
26 28 - **[Push mirrors](admins/mirrors.md)** — the secret key, outbound network and
27 29 SSRF behavior, the sync queue, and the tables involved.
30 +- **[Collaborators](admins/collaborators.md)** — access semantics, endpoints,
31 + and the `repository_collaborators` table (no configuration needed).
28 32
29 33 ### For maintainers
30 34
ADD docs/admins/collaborators.md +37 -0
diff --git a/docs/admins/collaborators.md b/docs/admins/collaborators.md
new file mode 100644
index 0000000..3ae7e40
--- /dev/null
+++ b/docs/admins/collaborators.md
@@ -0,0 +1,37 @@
1 +# Operating collaborators
2 +
3 +Collaborators let a repository owner grant other **local** users read+write access to a
4 +repository (one flat role). This page covers what an operator must know: there is no
5 +configuration, one new table, and no new proxy rules.
6 +
7 +## Configuration
8 +
9 +None. The feature is always on and needs no environment variables.
10 +
11 +## Endpoints
12 +
13 +Collaborator management happens on existing web UI paths — no new reverse-proxy rules:
14 +
15 +- `GET /repos/{owner}/{name}/settings/collaborators` — the settings page (owner-only;
16 + non-owners get `403`, users who cannot see the repository get `404`)
17 +- `POST /repos/{owner}/{name}/settings/collaborators` — add by username
18 +- `POST /repos/{owner}/{name}/settings/collaborators/{username}/remove` — remove
19 +
20 +## Access semantics
21 +
22 +- A collaborator gets **read and write** on the repository over every path — web UI, git
23 + over HTTP, git over SSH, REST API, and MCP — including read access to **private**
24 + repositories.
25 +- Collaborators can manage issues and merge requests like the owner.
26 +- Deleting the repository, managing push mirrors, and managing collaborators stay
27 + **owner-only**.
28 +- Removing a collaborator takes effect on the next request; there is no session to
29 + invalidate.
30 +
31 +## Tables
32 +
33 +| Table | Contents |
34 +|---|---|
35 +| `repository_collaborators` | One row per grant: `repository_id`, `user_id`, `created_at`; unique per (repository, user), rows cascade-deleted with the repository or the user |
36 +
37 +Added by migration `V12__repository_collaborators.sql`.
ADD docs/users/collaborators.md +40 -0
diff --git a/docs/users/collaborators.md b/docs/users/collaborators.md
new file mode 100644
index 0000000..2e0c82f
--- /dev/null
+++ b/docs/users/collaborators.md
@@ -0,0 +1,40 @@
1 +# Collaborators
2 +
3 +A collaborator is another user on your git-shark instance whom you grant **read and
4 +write** access to one of your repositories. There is one flat collaborator role — no
5 +permission levels, no teams. Adding a collaborator takes effect immediately; there is
6 +no invitation to accept.
7 +
8 +Only the **repository owner** can see and manage a repository's collaborators.
9 +
10 +## What a collaborator can do
11 +
12 +- Read the repository — browse it in the web UI and clone/fetch it over HTTP and SSH,
13 + including when the repository is **private**.
14 +- Push over HTTP and SSH.
15 +- Open and manage issues and merge requests, and moderate merge-request review comments,
16 + just like the owner.
17 +
18 +A collaborator can **not**:
19 +
20 +- Delete the repository.
21 +- Manage push mirrors.
22 +- Add or remove collaborators — granting access stays with the owner.
23 +
24 +## Adding a collaborator
25 +
26 +1. Open your repository. As the owner you'll find a **Collaborators** entry in the left
27 + sidebar (it links to `…/settings/collaborators`).
28 +2. Enter the user's exact **username** (the handle used in their profile URLs — there is
29 + no search or autocomplete) and click **Add**.
30 +
31 +The page tells you when the username doesn't exist, the user is already a collaborator,
32 +or you tried to add yourself.
33 +
34 +Only local users can be added — collaborators from other federated instances are not
35 +supported.
36 +
37 +## Removing a collaborator
38 +
39 +On the same page, click **Remove** next to the collaborator. Their read and write access
40 +ends immediately; on a private repository they lose all access.
MODIFY src/main/java/de/workaround/git/AccessPolicy.java +26 -4
diff --git a/src/main/java/de/workaround/git/AccessPolicy.java b/src/main/java/de/workaround/git/AccessPolicy.java
index dd194b1..2729f38 100644
--- a/src/main/java/de/workaround/git/AccessPolicy.java
+++ b/src/main/java/de/workaround/git/AccessPolicy.java
@@ -3,22 +3,33 @@
3 3 import de.workaround.model.Repository;
4 4 import de.workaround.model.User;
5 5 import jakarta.enterprise.context.ApplicationScoped;
6 +import jakarta.inject.Inject;
6 7
7 8 /**
8 - * Single authorization rule set shared by HTTP, SSH and UI: the owner has full access,
9 - * public repositories are world-readable, private repositories are owner-only.
9 + * Single authorization rule set shared by HTTP, SSH and UI: the owner has full access, collaborators
10 + * read and write repositories they were added to, public repositories are world-readable, private
11 + * repositories are visible to the owner and collaborators only.
10 12 */
11 13 @ApplicationScoped
12 14 public class AccessPolicy
13 15 {
16 + private final CollaboratorLookup collaborators;
17 +
18 + @Inject
19 + public AccessPolicy(CollaboratorLookup collaborators)
20 + {
21 + this.collaborators = collaborators;
22 + }
23 +
14 24 public boolean canRead(User user, Repository repository)
15 25 {
16 - return repository.visibility == Repository.Visibility.PUBLIC || isOwner(user, repository);
26 + return repository.visibility == Repository.Visibility.PUBLIC || isOwner(user, repository)
27 + || isCollaborator(user, repository);
17 28 }
18 29
19 30 public boolean canWrite(User user, Repository repository)
20 31 {
21 - return isOwner(user, repository);
32 + return isOwner(user, repository) || isCollaborator(user, repository);
22 33 }
23 34
24 35 private static boolean isOwner(User user, Repository repository)
@@ -26,4 +37,15 @@
26 37 return user != null && user.id != null && user.id.equals(repository.owner.id);
27 38 }
28 39
40 + private boolean isCollaborator(User user, Repository repository)
41 + {
42 + return user != null && user.id != null && collaborators.isCollaborator(user, repository);
43 + }
44 +
45 + /** How the policy asks whether a user holds a collaborator grant; implemented by {@link CollaboratorService}. */
46 + public interface CollaboratorLookup
47 + {
48 + boolean isCollaborator(User user, Repository repository);
49 + }
50 +
29 51 }
ADD src/main/java/de/workaround/git/CollaboratorService.java +75 -0
diff --git a/src/main/java/de/workaround/git/CollaboratorService.java b/src/main/java/de/workaround/git/CollaboratorService.java
new file mode 100644
index 0000000..13bb82b
--- /dev/null
+++ b/src/main/java/de/workaround/git/CollaboratorService.java
@@ -0,0 +1,75 @@
1 +package de.workaround.git;
2 +
3 +import java.util.List;
4 +
5 +import de.workaround.model.Repository;
6 +import de.workaround.model.RepositoryCollaborator;
7 +import de.workaround.model.User;
8 +import jakarta.enterprise.context.ApplicationScoped;
9 +import jakarta.inject.Inject;
10 +import jakarta.transaction.Transactional;
11 +
12 +/**
13 + * Manages the flat collaborator role on a repository: the owner grants and revokes read+write
14 + * access for other local users by username. Also serves as the {@link AccessPolicy}'s collaborator
15 + * lookup, so HTTP, SSH and UI authorization all see the same grants.
16 + */
17 +@ApplicationScoped
18 +public class CollaboratorService implements AccessPolicy.CollaboratorLookup
19 +{
20 + @Inject
21 + RepositoryCollaborator.Repo collaborators;
22 +
23 + @Inject
24 + User.Repo users;
25 +
26 + @Transactional
27 + public RepositoryCollaborator add(User actor, Repository repository, String username)
28 + {
29 + requireOwner(actor, repository);
30 + User user = users.findByUsername(username == null ? "" : username.strip())
31 + .orElseThrow(() -> new InvalidCollaboratorException("No user with that username exists."));
32 + if (user.id.equals(repository.owner.id))
33 + {
34 + throw new InvalidCollaboratorException("The owner cannot be added as a collaborator.");
35 + }
36 + if (collaborators.findByRepositoryAndUser(repository, user).isPresent())
37 + {
38 + throw new InvalidCollaboratorException("That user is already a collaborator.");
39 + }
40 + RepositoryCollaborator collaborator = new RepositoryCollaborator();
41 + collaborator.repository = repository;
42 + collaborator.user = user;
43 + collaborator.persist();
44 + return collaborator;
45 + }
46 +
47 + @Transactional
48 + public void remove(User actor, Repository repository, String username)
49 + {
50 + requireOwner(actor, repository);
51 + users.findByUsername(username == null ? "" : username.strip())
52 + .flatMap(user -> collaborators.findByRepositoryAndUser(repository, user))
53 + .ifPresent(collaborator -> collaborators.deleteById(collaborator.id));
54 + }
55 +
56 + public List<RepositoryCollaborator> list(Repository repository)
57 + {
58 + return collaborators.findByRepository(repository);
59 + }
60 +
61 + @Override
62 + public boolean isCollaborator(User user, Repository repository)
63 + {
64 + return collaborators.findByRepositoryAndUser(repository, user).isPresent();
65 + }
66 +
67 + private static void requireOwner(User actor, Repository repository)
68 + {
69 + if (actor == null || actor.id == null || !actor.id.equals(repository.owner.id))
70 + {
71 + throw new ForbiddenOperationException("Only the repository owner can manage collaborators");
72 + }
73 + }
74 +
75 +}
ADD src/main/java/de/workaround/git/InvalidCollaboratorException.java +14 -0
diff --git a/src/main/java/de/workaround/git/InvalidCollaboratorException.java b/src/main/java/de/workaround/git/InvalidCollaboratorException.java
new file mode 100644
index 0000000..090b23e
--- /dev/null
+++ b/src/main/java/de/workaround/git/InvalidCollaboratorException.java
@@ -0,0 +1,14 @@
1 +package de.workaround.git;
2 +
3 +/**
4 + * Rejected collaborator mutation: the username does not resolve to a local user, the user already
5 + * is a collaborator, or the user is the repository owner. Carries the user-facing message shown on
6 + * the collaborators settings page.
7 + */
8 +public class InvalidCollaboratorException extends RuntimeException
9 +{
10 + public InvalidCollaboratorException(String message)
11 + {
12 + super(message);
13 + }
14 +}
MODIFY src/main/java/de/workaround/git/IssueService.java +1 -1
diff --git a/src/main/java/de/workaround/git/IssueService.java b/src/main/java/de/workaround/git/IssueService.java
index b90c8fa..8a87b4b 100644
--- a/src/main/java/de/workaround/git/IssueService.java
+++ b/src/main/java/de/workaround/git/IssueService.java
@@ -102,7 +102,7 @@
102 102 {
103 103 if (!accessPolicy.canWrite(actor, repository))
104 104 {
105 - throw new ForbiddenOperationException("Only the repository owner can manage issues");
105 + throw new ForbiddenOperationException("Only the repository owner or a collaborator can manage issues");
106 106 }
107 107 }
108 108
MODIFY src/main/java/de/workaround/git/MergeRequestCommentService.java +1 -1
diff --git a/src/main/java/de/workaround/git/MergeRequestCommentService.java b/src/main/java/de/workaround/git/MergeRequestCommentService.java
index f31c9b6..9111df8 100644
--- a/src/main/java/de/workaround/git/MergeRequestCommentService.java
+++ b/src/main/java/de/workaround/git/MergeRequestCommentService.java
@@ -75,7 +75,7 @@
75 75 boolean isOwner = accessPolicy.canWrite(actor, managed.mergeRequest.repository);
76 76 if (!isAuthor && !isOwner)
77 77 {
78 - throw new ForbiddenOperationException("Only the comment author or the repository owner can delete it");
78 + throw new ForbiddenOperationException("Only the comment author, the repository owner or a collaborator can delete it");
79 79 }
80 80 comments.deleteById(managed.id);
81 81 }
MODIFY src/main/java/de/workaround/git/MergeRequestService.java +1 -1
diff --git a/src/main/java/de/workaround/git/MergeRequestService.java b/src/main/java/de/workaround/git/MergeRequestService.java
index 1d3fd2f..5d4ea1d 100644
--- a/src/main/java/de/workaround/git/MergeRequestService.java
+++ b/src/main/java/de/workaround/git/MergeRequestService.java
@@ -158,7 +158,7 @@
158 158 {
159 159 if (!accessPolicy.canWrite(actor, repository))
160 160 {
161 - throw new ForbiddenOperationException("Only the repository owner can manage merge requests");
161 + throw new ForbiddenOperationException("Only the repository owner or a collaborator can manage merge requests");
162 162 }
163 163 }
164 164
ADD src/main/java/de/workaround/model/RepositoryCollaborator.java +51 -0
diff --git a/src/main/java/de/workaround/model/RepositoryCollaborator.java b/src/main/java/de/workaround/model/RepositoryCollaborator.java
new file mode 100644
index 0000000..2eff345
--- /dev/null
+++ b/src/main/java/de/workaround/model/RepositoryCollaborator.java
@@ -0,0 +1,51 @@
1 +package de.workaround.model;
2 +
3 +import java.time.Instant;
4 +import java.util.List;
5 +import java.util.Optional;
6 +import java.util.UUID;
7 +
8 +import org.hibernate.annotations.processing.Find;
9 +import org.hibernate.annotations.processing.HQL;
10 +
11 +import io.quarkus.hibernate.panache.PanacheEntity;
12 +import io.quarkus.hibernate.panache.PanacheRepository;
13 +import jakarta.persistence.Entity;
14 +import jakarta.persistence.GeneratedValue;
15 +import jakarta.persistence.GenerationType;
16 +import jakarta.persistence.Id;
17 +import jakarta.persistence.ManyToOne;
18 +import jakarta.persistence.Table;
19 +import jakarta.persistence.UniqueConstraint;
20 +
21 +/**
22 + * Grants a local user read and write access to a repository they do not own — one flat
23 + * collaborator role, managed by the owner. Uniqueness is enforced per (repository, user).
24 + */
25 +@Entity
26 +@Table(name = "repository_collaborators",
27 + uniqueConstraints = @UniqueConstraint(columnNames = { "repository_id", "user_id" }))
28 +public class RepositoryCollaborator implements PanacheEntity.Managed
29 +{
30 + @Id
31 + @GeneratedValue(strategy = GenerationType.UUID)
32 + public UUID id;
33 +
34 + @ManyToOne(optional = false)
35 + public Repository repository;
36 +
37 + @ManyToOne(optional = false)
38 + public User user;
39 +
40 + public Instant createdAt = Instant.now();
41 +
42 + public interface Repo extends PanacheRepository.Managed<RepositoryCollaborator, UUID>
43 + {
44 + @Find
45 + Optional<RepositoryCollaborator> findByRepositoryAndUser(Repository repository, User user);
46 +
47 + @HQL("where repository = :repository order by user.username")
48 + List<RepositoryCollaborator> findByRepository(Repository repository);
49 + }
50 +
51 +}
ADD src/main/java/de/workaround/web/CollaboratorResource.java +123 -0
diff --git a/src/main/java/de/workaround/web/CollaboratorResource.java b/src/main/java/de/workaround/web/CollaboratorResource.java
new file mode 100644
index 0000000..7e087ab
--- /dev/null
+++ b/src/main/java/de/workaround/web/CollaboratorResource.java
@@ -0,0 +1,123 @@
1 +package de.workaround.web;
2 +
3 +import java.net.URI;
4 +import java.util.List;
5 +
6 +import de.workaround.account.CurrentUser;
7 +import de.workaround.git.AccessPolicy;
8 +import de.workaround.git.ForbiddenOperationException;
9 +import de.workaround.git.GitRepositoryService;
10 +import de.workaround.git.InvalidCollaboratorException;
11 +import de.workaround.model.Repository;
12 +import de.workaround.model.RepositoryCollaborator;
13 +import de.workaround.model.User;
14 +import io.quarkus.qute.CheckedTemplate;
15 +import io.quarkus.qute.TemplateInstance;
16 +import jakarta.inject.Inject;
17 +import jakarta.ws.rs.Consumes;
18 +import jakarta.ws.rs.FormParam;
19 +import jakarta.ws.rs.GET;
20 +import jakarta.ws.rs.NotFoundException;
21 +import jakarta.ws.rs.POST;
22 +import jakarta.ws.rs.Path;
23 +import jakarta.ws.rs.PathParam;
24 +import jakarta.ws.rs.Produces;
25 +import jakarta.ws.rs.core.Context;
26 +import jakarta.ws.rs.core.MediaType;
27 +import jakarta.ws.rs.core.Response;
28 +import jakarta.ws.rs.core.UriInfo;
29 +
30 +/**
31 + * Owner-only collaborator management under the repository settings. Non-owners who can see the
32 + * repository get 403; users who cannot see it get 404 so private repositories stay hidden.
33 + * Managing collaborators deliberately stays owner-only — a collaborator's write access covers git
34 + * and repo content, not granting access to others.
35 + */
36 +@Path("/repos/{owner}/{name}/settings/collaborators")
37 +@Produces(MediaType.TEXT_HTML)
38 +public class CollaboratorResource
39 +{
40 + @CheckedTemplate
41 + static class Templates
42 + {
43 + static native TemplateInstance collaborators(Repository repo, RepoNav nav,
44 + List<RepositoryCollaborator> collaborators, String error);
45 + }
46 +
47 + @Inject
48 + CurrentUser currentUser;
49 +
50 + @Inject
51 + GitRepositoryService service;
52 +
53 + @Inject
54 + AccessPolicy accessPolicy;
55 +
56 + @Inject
57 + de.workaround.git.CollaboratorService collaboratorService;
58 +
59 + @Inject
60 + RepoNavService repoNav;
61 +
62 + @Context
63 + UriInfo uriInfo;
64 +
65 + @GET
66 + public TemplateInstance page(@PathParam("owner") String owner, @PathParam("name") String name)
67 + {
68 + Repository repo = requireOwned(owner, name);
69 + return Templates.collaborators(repo, repoNav.build(repo, uriInfo), collaboratorService.list(repo), null);
70 + }
71 +
72 + @POST
73 + @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
74 + public Response add(@PathParam("owner") String owner, @PathParam("name") String name,
75 + @FormParam("username") String username)
76 + {
77 + Repository repo = requireOwned(owner, name);
78 + try
79 + {
80 + collaboratorService.add(currentUser.require(), repo, username);
81 + }
82 + catch (InvalidCollaboratorException e)
83 + {
84 + return Response.status(Response.Status.BAD_REQUEST)
85 + .entity(Templates.collaborators(repo, repoNav.build(repo, uriInfo), collaboratorService.list(repo),
86 + e.getMessage()))
87 + .build();
88 + }
89 + return backToSettings(owner, name);
90 + }
91 +
92 + @POST
93 + @Path("{username}/remove")
94 + public Response remove(@PathParam("owner") String owner, @PathParam("name") String name,
95 + @PathParam("username") String username)
96 + {
97 + Repository repo = requireOwned(owner, name);
98 + collaboratorService.remove(currentUser.require(), repo, username);
99 + return backToSettings(owner, name);
100 + }
101 +
102 + private Repository requireOwned(String owner, String name)
103 + {
104 + Repository repo = service.find(owner, name).orElseThrow(NotFoundException::new);
105 + User user = currentUser.get();
106 + if (!accessPolicy.canRead(user, repo))
107 + {
108 + // hide existence of private repositories
109 + throw new NotFoundException();
110 + }
111 + if (user == null || !user.id.equals(repo.owner.id))
112 + {
113 + throw new ForbiddenOperationException("Only the repository owner can manage collaborators");
114 + }
115 + return repo;
116 + }
117 +
118 + private static Response backToSettings(String owner, String name)
119 + {
120 + return Response.seeOther(URI.create("/repos/" + owner + "/" + name + "/settings/collaborators")).build();
121 + }
122 +
123 +}
MODIFY src/main/java/de/workaround/web/IssueResource.java +2 -2
diff --git a/src/main/java/de/workaround/web/IssueResource.java b/src/main/java/de/workaround/web/IssueResource.java
index caebffb..49febaa 100644
--- a/src/main/java/de/workaround/web/IssueResource.java
+++ b/src/main/java/de/workaround/web/IssueResource.java
@@ -79,10 +79,10 @@
79 79 public TemplateInstance newForm(@PathParam("owner") String owner, @PathParam("name") String name)
80 80 {
81 81 Repository repo = requireReadable(owner, name);
82 - // only the owner may open an issue, so only the owner sees the form
82 + // only users with write access (owner or collaborator) may open an issue
83 83 if (!accessPolicy.canWrite(currentUser.get(), repo))
84 84 {
85 - throw new de.workaround.git.ForbiddenOperationException("Only the repository owner can open issues");
85 + throw new de.workaround.git.ForbiddenOperationException("Only the repository owner or a collaborator can open issues");
86 86 }
87 87 return Templates.newIssue(repo, repoNav.build(repo, uriInfo));
88 88 }
MODIFY src/main/java/de/workaround/web/MergeRequestResource.java +1 -1
diff --git a/src/main/java/de/workaround/web/MergeRequestResource.java b/src/main/java/de/workaround/web/MergeRequestResource.java
index 1bbf892..c78e30f 100644
--- a/src/main/java/de/workaround/web/MergeRequestResource.java
+++ b/src/main/java/de/workaround/web/MergeRequestResource.java
@@ -109,7 +109,7 @@
109 109 Repository repo = requireReadable(owner, name);
110 110 if (!accessPolicy.canWrite(currentUser.get(), repo))
111 111 {
112 - throw new ForbiddenOperationException("Only the repository owner can open merge requests");
112 + throw new ForbiddenOperationException("Only the repository owner or a collaborator can open merge requests");
113 113 }
114 114 Path path = service.repositoryPath(repo);
115 115 List<String> branches = browse.branches(path).stream().map(GitBrowseService.BranchInfo::name).toList();
MODIFY src/main/java/de/workaround/web/RepoNav.java +3 -3
diff --git a/src/main/java/de/workaround/web/RepoNav.java b/src/main/java/de/workaround/web/RepoNav.java
index 9625043..0a17aac 100644
--- a/src/main/java/de/workaround/web/RepoNav.java
+++ b/src/main/java/de/workaround/web/RepoNav.java
@@ -7,8 +7,8 @@
7 7 * request path (so the pin toggle returns the user to where they were), pin state, and the per-section counts shown
8 8 * next to each nav item. Assembled once per request by {@link RepoNavService}.
9 9 */
10 -public record RepoNav(Repository repo, boolean loggedIn, boolean pinned, boolean empty, String defaultBranch,
11 - int commitCount, int branchCount, int tagCount, long openIssueCount, long openMrCount, String httpUrl,
12 - String sshUrl, String currentPath)
10 +public record RepoNav(Repository repo, boolean loggedIn, boolean isOwner, boolean pinned, boolean empty,
11 + String defaultBranch, int commitCount, int branchCount, int tagCount, long openIssueCount, long openMrCount,
12 + String httpUrl, String sshUrl, String currentPath)
13 13 {
14 14 }
MODIFY src/main/java/de/workaround/web/RepoNavService.java +2 -1
diff --git a/src/main/java/de/workaround/web/RepoNavService.java b/src/main/java/de/workaround/web/RepoNavService.java
index 5b03f35..f41ee6d 100644
--- a/src/main/java/de/workaround/web/RepoNavService.java
+++ b/src/main/java/de/workaround/web/RepoNavService.java
@@ -51,6 +51,7 @@
51 51 String defaultBranch = empty ? null : browse.defaultBranch(path);
52 52 User user = currentUser.get();
53 53 boolean loggedIn = user != null;
54 + boolean isOwner = loggedIn && user.id.equals(repo.owner.id);
54 55 boolean pinned = loggedIn && pinService.isPinned(user, repo);
55 56 int commitCount = empty ? 0 : browse.commitCount(path, defaultBranch);
56 57 int branchCount = browse.branches(path).size();
@@ -61,7 +62,7 @@
61 62 .toString();
62 63 String sshUrl = "ssh://git@" + uriInfo.getBaseUri().getHost() + ":" + sshPort + "/" + repo.owner.username
63 64 + "/" + repo.name + ".git";
64 - return new RepoNav(repo, loggedIn, pinned, empty, defaultBranch, commitCount, branchCount, tagCount,
65 + return new RepoNav(repo, loggedIn, isOwner, pinned, empty, defaultBranch, commitCount, branchCount, tagCount,
65 66 openIssueCount, openMrCount, httpUrl, sshUrl, uriInfo.getRequestUri().getRawPath());
66 67 }
67 68 }
ADD src/main/resources/db/migration/V12__repository_collaborators.sql +8 -0
diff --git a/src/main/resources/db/migration/V12__repository_collaborators.sql b/src/main/resources/db/migration/V12__repository_collaborators.sql
new file mode 100644
index 0000000..1189c50
--- /dev/null
+++ b/src/main/resources/db/migration/V12__repository_collaborators.sql
@@ -0,0 +1,8 @@
1 +create table repository_collaborators
2 +(
3 + id uuid primary key,
4 + repository_id uuid not null references repositories (id) on delete cascade,
5 + user_id uuid not null references users (id) on delete cascade,
6 + created_at timestamptz not null default now(),
7 + unique (repository_id, user_id)
8 +);
ADD src/main/resources/templates/CollaboratorResource/collaborators.html +35 -0
diff --git a/src/main/resources/templates/CollaboratorResource/collaborators.html b/src/main/resources/templates/CollaboratorResource/collaborators.html
new file mode 100644
index 0000000..265cd4f
--- /dev/null
+++ b/src/main/resources/templates/CollaboratorResource/collaborators.html
@@ -0,0 +1,35 @@
1 +{#include layout}
2 +{#title}Collaborators – {repo.name}{/title}
3 +<div class="repo-layout">
4 + {#include RepositoryResource/sidebar nav=nav active='collaborators' /}
5 + <section class="repo-main">
6 + <h2>Collaborators</h2>
7 + <p>Collaborators can read and push to this repository, including when it is private. Only you can manage them.</p>
8 + {#if error}
9 + <p class="error">{error}</p>
10 + {/if}
11 + {#if collaborators.empty}
12 + <p>No collaborators yet.</p>
13 + {#else}
14 + <table>
15 + <tr><th>User</th><th class="actions"></th></tr>
16 + {#for collaborator in collaborators}
17 + <tr>
18 + <td>{#avatar user=collaborator.user /} {collaborator.user.username}</td>
19 + <td class="actions">
20 + <form class="inline" method="post" action="/repos/{repo.owner.username}/{repo.name}/settings/collaborators/{collaborator.user.username}/remove">
21 + <button class="btn btn-danger btn-sm">Remove</button>
22 + </form>
23 + </td>
24 + </tr>
25 + {/for}
26 + </table>
27 + {/if}
28 + <h3>Add collaborator</h3>
29 + <form method="post" action="/repos/{repo.owner.username}/{repo.name}/settings/collaborators">
30 + <p><label>Username <input name="username" required></label></p>
31 + <button class="btn btn-primary">Add</button>
32 + </form>
33 + </section>
34 +</div>
35 +{/include}
MODIFY src/main/resources/templates/RepositoryResource/sidebar.html +3 -0
diff --git a/src/main/resources/templates/RepositoryResource/sidebar.html b/src/main/resources/templates/RepositoryResource/sidebar.html
index 6e6e3cf..1bed5c7 100644
--- a/src/main/resources/templates/RepositoryResource/sidebar.html
+++ b/src/main/resources/templates/RepositoryResource/sidebar.html
@@ -28,6 +28,9 @@
28 28 <a class="{#if active == 'tags'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/tags"><span class="g">⬡</span> Tags <span class="ct">{nav.tagCount}</span></a>
29 29 <a class="{#if active == 'issues'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/issues"><span class="g">◇</span> Issues <span class="ct">{nav.openIssueCount}</span></a>
30 30 <a class="{#if active == 'merge-requests'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/merge-requests"><span class="g">⇄</span> Merge requests <span class="ct">{nav.openMrCount}</span></a>
31 + {#if nav.isOwner}
32 + <a class="{#if active == 'collaborators'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/settings/collaborators"><span class="g">⚇</span> Collaborators</a>
33 + {/if}
31 34 </nav>
32 35 </aside>
33 36
MODIFY src/test/java/de/workaround/git/AccessPolicyTest.java +37 -2
diff --git a/src/test/java/de/workaround/git/AccessPolicyTest.java b/src/test/java/de/workaround/git/AccessPolicyTest.java
index 904e157..4c39df2 100644
--- a/src/test/java/de/workaround/git/AccessPolicyTest.java
+++ b/src/test/java/de/workaround/git/AccessPolicyTest.java
@@ -1,5 +1,6 @@
1 1 package de.workaround.git;
2 2
3 +import java.util.Set;
3 4 import java.util.UUID;
4 5
5 6 import org.junit.jupiter.api.Test;
@@ -12,12 +13,16 @@
12 13
13 14 class AccessPolicyTest
14 15 {
15 - private final AccessPolicy policy = new AccessPolicy();
16 -
17 16 private final User owner = user();
18 17
19 18 private final User stranger = user();
20 19
20 + private final User collaborator = user();
21 +
22 + /** Policy whose collaborator lookup knows exactly one collaborator. */
23 + private final AccessPolicy policy = new AccessPolicy(
24 + (user, repository) -> Set.of(collaborator.id).contains(user.id));
25 +
21 26 @Test
22 27 void ownerReadsAndWritesOwnRepository()
23 28 {
@@ -48,6 +53,36 @@
48 53 assertFalse(policy.canWrite(stranger, repo));
49 54 }
50 55
56 + @Test
57 + void collaboratorReadsAndWritesPrivateRepository()
58 + {
59 + Repository repo = repo(owner, Repository.Visibility.PRIVATE);
60 +
61 + assertTrue(policy.canRead(collaborator, repo));
62 + assertTrue(policy.canWrite(collaborator, repo));
63 + }
64 +
65 + @Test
66 + void collaboratorWritesPublicRepository()
67 + {
68 + Repository repo = repo(owner, Repository.Visibility.PUBLIC);
69 +
70 + assertTrue(policy.canWrite(collaborator, repo));
71 + }
72 +
73 + @Test
74 + void anonymousIsNeverTreatedAsCollaborator()
75 + {
76 + // a lookup that would blow up on null users must never be consulted for anonymous requests
77 + AccessPolicy paranoid = new AccessPolicy((user, repository) -> {
78 + throw new AssertionError("lookup must not be called for anonymous users");
79 + });
80 + Repository repo = repo(owner, Repository.Visibility.PRIVATE);
81 +
82 + assertFalse(paranoid.canRead(null, repo));
83 + assertFalse(paranoid.canWrite(null, repo));
84 + }
85 +
51 86 private static User user()
52 87 {
53 88 User user = new User();
MODIFY src/test/java/de/workaround/http/GitSmartHttpTest.java +35 -0
diff --git a/src/test/java/de/workaround/http/GitSmartHttpTest.java b/src/test/java/de/workaround/http/GitSmartHttpTest.java
index f33065d..5415b01 100644
--- a/src/test/java/de/workaround/http/GitSmartHttpTest.java
+++ b/src/test/java/de/workaround/http/GitSmartHttpTest.java
@@ -22,6 +22,7 @@
22 22 import jakarta.transaction.Transactional;
23 23
24 24 import static org.junit.jupiter.api.Assertions.assertEquals;
25 +import static org.junit.jupiter.api.Assertions.assertNotEquals;
25 26 import static org.junit.jupiter.api.Assertions.assertThrows;
26 27 import static org.junit.jupiter.api.Assertions.assertTrue;
27 28
@@ -118,6 +119,40 @@
118 119 assertEquals(before, mainRef(service.repositoryPath(repo)), "refs must not change on forbidden push");
119 120 }
120 121
122 + @Inject
123 + de.workaround.git.CollaboratorService collaboratorService;
124 +
125 + @Test
126 + void collaboratorClonesAndPushesPrivateRepository() throws Exception
127 + {
128 + User owner = persistUser();
129 + User collaborator = persistUser();
130 + Repository repo = createRepo(owner, "collab-push", Repository.Visibility.PRIVATE);
131 + seedCommit(service.repositoryPath(repo));
132 + addCollaborator(repo, collaborator);
133 + ObjectId before = mainRef(service.repositoryPath(repo));
134 + String token = createToken(collaborator);
135 + var credentials = new UsernamePasswordCredentialsProvider(collaborator.username, token);
136 +
137 + Path work = Files.createTempDirectory("collab-push");
138 + try (Git git = cloneOver(httpUrl(owner, "collab-push"), work, credentials))
139 + {
140 + commitFile(git, work, "collab.txt");
141 + git.push()
142 + .setCredentialsProvider(credentials)
143 + .setRefSpecs(new RefSpec("HEAD:refs/heads/main"))
144 + .call();
145 + }
146 +
147 + assertNotEquals(before, mainRef(service.repositoryPath(repo)), "collaborator push must advance main");
148 + }
149 +
150 + @Transactional
151 + void addCollaborator(Repository repo, User user)
152 + {
153 + collaboratorService.add(repo.owner, repo, user.username);
154 + }
155 +
121 156 @Test
122 157 void privateRepositoryRequiresAuthenticationForClone() throws Exception
123 158 {
MODIFY src/test/java/de/workaround/ssh/SshGitAccessTest.java +37 -0
diff --git a/src/test/java/de/workaround/ssh/SshGitAccessTest.java b/src/test/java/de/workaround/ssh/SshGitAccessTest.java
index 7650419..30bb795 100644
--- a/src/test/java/de/workaround/ssh/SshGitAccessTest.java
+++ b/src/test/java/de/workaround/ssh/SshGitAccessTest.java
@@ -161,6 +161,43 @@
161 161 assertEquals(before, mainRef(service.repositoryPath(repo)), "refs must not change on denied push");
162 162 }
163 163
164 + @Inject
165 + de.workaround.git.CollaboratorService collaboratorService;
166 +
167 + @Test
168 + void collaboratorClonesAndPushesPrivateRepositoryOverSsh() throws Exception
169 + {
170 + KeyPair ownerKey = generateKeyPair();
171 + User owner = persistUserWithKey(ownerKey);
172 + KeyPair collaboratorKey = generateKeyPair();
173 + User collaborator = persistUserWithKey(collaboratorKey);
174 + Repository repo = service.create(owner, "ssh-collab", Repository.Visibility.PRIVATE, null);
175 + GitSmartHttpTest.seedCommit(service.repositoryPath(repo));
176 + addCollaborator(repo, collaborator);
177 + ObjectId before = mainRef(service.repositoryPath(repo));
178 +
179 + Path work = Files.createTempDirectory("ssh-collab");
180 + try (Git git = cloneOverSsh(sshUrl(owner, "ssh-collab"), work, collaboratorKey))
181 + {
182 + Files.writeString(work.resolve("collab.txt"), "via collaborator\n");
183 + git.add().addFilepattern(".").call();
184 + git.commit().setMessage("collaborator push").setSign(false)
185 + .setAuthor("t", "t@example.com").setCommitter("t", "t@example.com").call();
186 + git.push()
187 + .setTransportConfigCallback(SshGitAccessTest::configureTransport)
188 + .setRefSpecs(new RefSpec("HEAD:refs/heads/main"))
189 + .call();
190 + }
191 +
192 + assertNotEquals(before, mainRef(service.repositoryPath(repo)), "collaborator push must advance main");
193 + }
194 +
195 + @Transactional
196 + void addCollaborator(Repository repo, User user)
197 + {
198 + collaboratorService.add(repo.owner, repo, user.username);
199 + }
200 +
164 201 @Test
165 202 void hostKeyIsPersistedAndStable() throws Exception
166 203 {
ADD src/test/java/de/workaround/web/CollaboratorSettingsTest.java +283 -0
diff --git a/src/test/java/de/workaround/web/CollaboratorSettingsTest.java b/src/test/java/de/workaround/web/CollaboratorSettingsTest.java
new file mode 100644
index 0000000..c8891cb
--- /dev/null
+++ b/src/test/java/de/workaround/web/CollaboratorSettingsTest.java
@@ -0,0 +1,283 @@
1 +package de.workaround.web;
2 +
3 +import java.util.List;
4 +import java.util.UUID;
5 +
6 +import org.junit.jupiter.api.Test;
7 +
8 +import de.workaround.git.CollaboratorService;
9 +import de.workaround.git.GitRepositoryService;
10 +import de.workaround.model.Repository;
11 +import de.workaround.model.RepositoryCollaborator;
12 +import de.workaround.model.User;
13 +import io.quarkus.test.junit.QuarkusTest;
14 +import io.quarkus.test.security.TestSecurity;
15 +import jakarta.inject.Inject;
16 +import jakarta.transaction.Transactional;
17 +
18 +import static io.restassured.RestAssured.given;
19 +import static org.hamcrest.CoreMatchers.containsString;
20 +import static org.hamcrest.CoreMatchers.not;
21 +import static org.junit.jupiter.api.Assertions.assertEquals;
22 +
23 +/**
24 + * Owner-only collaborator management under /repos/{owner}/{name}/settings/collaborators: the owner
25 + * adds and removes local users by username, errors surface on the page, and non-owners get 403
26 + * (404 when they cannot even see the repository).
27 + */
28 +@QuarkusTest
29 +class CollaboratorSettingsTest
30 +{
31 + private static final String OWNER = "collab-owner";
32 +
33 + private static final String STRANGER = "collab-stranger";
34 +
35 + private static final String MEMBER = "collab-member";
36 +
37 + @Inject
38 + GitRepositoryService service;
39 +
40 + @Inject
41 + CollaboratorService collaboratorService;
42 +
43 + @Inject
44 + User.Repo users;
45 +
46 + @Test
47 + @TestSecurity(user = OWNER)
48 + void ownerSeesCollaboratorsPageAndAddsUser()
49 + {
50 + persistUser(MEMBER);
51 + Repository repo = repoOwnedBy(OWNER, "collab-add", Repository.Visibility.PUBLIC);
52 +
53 + given()
54 + .when().get(settingsPath(repo))
55 + .then().statusCode(200)
56 + .body(containsString("Collaborators"));
57 +
58 + given()
59 + .redirects().follow(false)
60 + .formParam("username", MEMBER)
61 + .when().post(settingsPath(repo))
62 + .then().statusCode(303);
63 +
64 + given()
65 + .when().get(settingsPath(repo))
66 + .then().statusCode(200)
67 + .body(containsString(MEMBER));
68 +
69 + assertEquals(1, listCollaborators(repo).size());
70 + }
71 +
72 + @Test
73 + @TestSecurity(user = OWNER)
74 + void ownerRemovesCollaborator()
75 + {
76 + User member = persistUser(MEMBER);
77 + Repository repo = repoOwnedBy(OWNER, "collab-remove", Repository.Visibility.PUBLIC);
78 + addCollaborator(repo, member);
79 +
80 + given()
81 + .redirects().follow(false)
82 + .when().post(settingsPath(repo) + "/" + MEMBER + "/remove")
83 + .then().statusCode(303);
84 +
85 + assertEquals(0, listCollaborators(repo).size());
86 + given()
87 + .when().get(settingsPath(repo))
88 + .then().statusCode(200)
89 + .body(not(containsString(MEMBER)));
90 + }
91 +
92 + @Test
93 + @TestSecurity(user = OWNER)
94 + void addingUnknownUsernameShowsError()
95 + {
96 + Repository repo = repoOwnedBy(OWNER, "collab-unknown", Repository.Visibility.PUBLIC);
97 +
98 + given()
99 + .redirects().follow(false)
100 + .formParam("username", "does-not-exist")
101 + .when().post(settingsPath(repo))
102 + .then().statusCode(400)
103 + .body(containsString("No user with that username exists."));
104 + }
105 +
106 + @Test
107 + @TestSecurity(user = OWNER)
108 + void addingExistingCollaboratorShowsError()
109 + {
110 + User member = persistUser(MEMBER);
111 + Repository repo = repoOwnedBy(OWNER, "collab-dup", Repository.Visibility.PUBLIC);
112 + addCollaborator(repo, member);
113 +
114 + given()
115 + .redirects().follow(false)
116 + .formParam("username", MEMBER)
117 + .when().post(settingsPath(repo))
118 + .then().statusCode(400)
119 + .body(containsString("That user is already a collaborator."));
120 +
121 + assertEquals(1, listCollaborators(repo).size());
122 + }
123 +
124 + @Test
125 + @TestSecurity(user = OWNER)
126 + void addingTheOwnerShowsError()
127 + {
128 + Repository repo = repoOwnedBy(OWNER, "collab-self", Repository.Visibility.PUBLIC);
129 +
130 + given()
131 + .redirects().follow(false)
132 + .formParam("username", OWNER)
133 + .when().post(settingsPath(repo))
134 + .then().statusCode(400)
135 + .body(containsString("The owner cannot be added as a collaborator."));
136 +
137 + assertEquals(0, listCollaborators(repo).size());
138 + }
139 +
140 + @Test
141 + @TestSecurity(user = STRANGER)
142 + void nonOwnerGets403OnPublicRepositorySettings()
143 + {
144 + persistUser(STRANGER);
145 + Repository repo = repoOwnedBy(OWNER, "collab-403", Repository.Visibility.PUBLIC);
146 +
147 + given()
148 + .when().get(settingsPath(repo))
149 + .then().statusCode(403);
150 +
151 + given()
152 + .redirects().follow(false)
153 + .formParam("username", STRANGER)
154 + .when().post(settingsPath(repo))
155 + .then().statusCode(403);
156 +
157 + assertEquals(0, listCollaborators(repo).size());
158 + }
159 +
160 + @Test
161 + @TestSecurity(user = MEMBER)
162 + void collaboratorCannotManageCollaborators()
163 + {
164 + User member = persistUser(MEMBER);
165 + Repository repo = repoOwnedBy(OWNER, "collab-member-403", Repository.Visibility.PUBLIC);
166 + addCollaborator(repo, member);
167 +
168 + given()
169 + .when().get(settingsPath(repo))
170 + .then().statusCode(403);
171 + }
172 +
173 + @Test
174 + @TestSecurity(user = STRANGER)
175 + void privateRepositorySettingsAreHiddenFromStrangers()
176 + {
177 + persistUser(STRANGER);
178 + Repository repo = repoOwnedBy(OWNER, "collab-hidden", Repository.Visibility.PRIVATE);
179 +
180 + given()
181 + .when().get(settingsPath(repo))
182 + .then().statusCode(404);
183 + }
184 +
185 + @Test
186 + void anonymousGets403OnPublicRepositorySettings()
187 + {
188 + Repository repo = repoOwnedBy(OWNER, "collab-anon", Repository.Visibility.PUBLIC);
189 +
190 + given()
191 + .when().get(settingsPath(repo))
192 + .then().statusCode(403);
193 + }
194 +
195 + @Test
196 + @TestSecurity(user = MEMBER)
197 + void collaboratorReadsPrivateRepositoryInUi()
198 + {
199 + User member = persistUser(MEMBER);
200 + Repository repo = repoOwnedBy(OWNER, "collab-private-read", Repository.Visibility.PRIVATE);
201 + addCollaborator(repo, member);
202 +
203 + given()
204 + .when().get("/repos/" + OWNER + "/" + repo.name)
205 + .then().statusCode(200);
206 + }
207 +
208 + @Test
209 + @TestSecurity(user = STRANGER)
210 + void strangerStillCannotReadPrivateRepositoryInUi()
211 + {
212 + persistUser(STRANGER);
213 + Repository repo = repoOwnedBy(OWNER, "collab-private-hidden", Repository.Visibility.PRIVATE);
214 +
215 + given()
216 + .when().get("/repos/" + OWNER + "/" + repo.name)
217 + .then().statusCode(404);
218 + }
219 +
220 + @Test
221 + @TestSecurity(user = OWNER)
222 + void ownerSeesSettingsLinkInSidebar()
223 + {
224 + Repository repo = repoOwnedBy(OWNER, "collab-nav", Repository.Visibility.PUBLIC);
225 +
226 + given()
227 + .when().get("/repos/" + OWNER + "/" + repo.name)
228 + .then().statusCode(200)
229 + .body(containsString("settings/collaborators"));
230 + }
231 +
232 + @Test
233 + @TestSecurity(user = STRANGER)
234 + void nonOwnerDoesNotSeeSettingsLinkInSidebar()
235 + {
236 + persistUser(STRANGER);
237 + Repository repo = repoOwnedBy(OWNER, "collab-nav-hidden", Repository.Visibility.PUBLIC);
238 +
239 + given()
240 + .when().get("/repos/" + OWNER + "/" + repo.name)
241 + .then().statusCode(200)
242 + .body(not(containsString("settings/collaborators")));
243 + }
244 +
245 + private static String settingsPath(Repository repo)
246 + {
247 + return "/repos/" + OWNER + "/" + repo.name + "/settings/collaborators";
248 + }
249 +
250 + private Repository repoOwnedBy(String username, String name, Repository.Visibility visibility)
251 + {
252 + User owner = persistUser(username);
253 + return service.create(owner, name + "-" + UUID.randomUUID().toString().substring(0, 8), visibility, null);
254 + }
255 +
256 + @Transactional
257 + void addCollaborator(Repository repo, User member)
258 + {
259 + collaboratorService.add(repo.owner, repo, member.username);
260 + }
261 +
262 + @Transactional
263 + List<RepositoryCollaborator> listCollaborators(Repository repo)
264 + {
265 + return collaboratorService.list(repo);
266 + }
267 +
268 + @Transactional
269 + User persistUser(String name)
270 + {
271 + User existing = users.findByOidcSubOptional(name).orElse(null);
272 + if (existing != null)
273 + {
274 + return existing;
275 + }
276 + User user = new User();
277 + user.oidcSub = name;
278 + user.username = name;
279 + user.persist();
280 + return user;
281 + }
282 +
283 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog