Skip to content

Give projects an owning team, and make team role decide what a team confers - #1282

Merged
CollinBeczak merged 5 commits into
mainfrom
project-owner-team
Sep 16, 2026
Merged

CollinBeczak merged 5 commits into
mainfrom
project-owner-team

Conversation

@CollinBeczak

Copy link
Copy Markdown
Contributor

Merge first: both frontend branches depend on the endpoints and behaviour here.

Projects can be owned by a team

A challenge could already be given to a team; a project could not. Evolution 128
adds owner_team_id with the same foreign key and partial index the challenge
column has. That team's owners, admins and managers run the project, and its
approved image becomes the picture on the project's card, derived in the JSON
exactly as a challenge's is.

Handing a project to a team requires managing that team — otherwise anyone could
hang their project off another team's name, and its image, by guessing an id.

Teams can be granted a role on a challenge

Users could already be. Teams could not, at any layer: GrantTarget.challenge
existed and hasChallengeGrant would have honoured a team's grant, but there was
no service method, controller action or route to create one. Granting requires
admin on the challenge, however that admin is held.

One resolver instead of four

"May this user touch this?" was answered independently in hasProjectAccess, in
the challenge branch of hasObjectAccess, and in the managed-project listing —
each consulting a different subset of the ways in. That is how a team's grant
came to reach projects (folded into the user's grant list by UserRepository)
while reaching challenges not at all.

Permission.effectiveRole is now the single answer, and each check is one
comparison against it. Ownership and attachment collapse into the same question:
which teams are associated with this work, and what does this person do in them.

⚠️ What a team confers changes

A grant to a team used to hand every member the role it named, so a plain
member of a team added as Admin administered the project. Members now get the
role they hold in the team:

Role in the team On the work
Owner / Admin Admin
Manager Write
Member nothing

This removes access that exists today. Run audit.sql against staging and
production before deploying — it reports, per person, who keeps admin, who drops
to write, and who loses access outright.

Taking work away from a team

Giving a challenge to a team required running that team; taking one away required
nothing beyond being able to edit it, so a project admin could reassign a team's
challenge, or hand it back to nobody, with the team having no say. A change of
owning team now needs the mover to run the team it is leaving. Clearing counts as
a change — otherwise the rule is a formality, since anyone could clear and then
set.

One consequence worth knowing: if an owning team loses all its managers, only a
superuser can move that challenge again.

Testing

353 passing. New TeamAccessSpec covers the matrix — each team role against an
attached team and an owning team, a stranger, a challenge grant that must not
leak to its project, inheritance from the project, and an invitation that confers
nothing until accepted. ProjectSpec covers the derived image url. Evolution 128
has been applied to a virgin database and exercised over HTTP.

Projects still have the looser transfer rule (their source side is unguarded) —
deliberately left, since the ask named challenges.

Projects gain an owning team, the same way challenges already had one.
Its owners, admins and managers manage the project, and its approved
image becomes the picture on the project's card, derived in the json the
way a challenge's is. Null for the projects nobody has assigned, which
keep working purely off grants.

Ownership is distinct from granting a team a role on the project, which
stays what it was: one of several teams helping run it. Ownership is
singular, and only it puts a picture on the card. Handing a project to a
team requires managing that team -- otherwise anyone could hang their
project off another team's name, and its image, by guessing an id --
which is the rule challenges already enforce, generalized here.

Teams can also now be granted a role on a single challenge. Users could
already be, and hasChallengeGrant would have honoured a team's grant, but
there was no service method, endpoint or route to create one. Granting
requires admin on the challenge, however that admin is held.
Every "may this user touch this" question was answered independently --
in hasProjectAccess, in the challenge branch, in the managed-project
listing -- each consulting a different subset of the ways in. That is how
a team's grant came to reach projects (folded into the user's grant list
by UserRepository) while reaching challenges not at all, and why adding a
route meant finding every caller.

Permission.effectiveRole is now the single answer, for a project and for
a challenge, and the checks are one comparison against it. Ownership and
attachment collapse into the same question: which teams are associated
with this work, and what does this person do in them.

What a team confers changes with it. A grant to a team used to hand every
member the role it named, so a plain member of a team added as Admin
administered the project. Members now get the role they hold in the team
-- owners and admins administer, managers may edit, plain members get
nothing -- which is the line ownership already drew. The role on the
grant is no longer read.

TeamAccessSpec covers the matrix: each team role against an attached team
and an owning team, a stranger, a challenge grant that must not leak to
its project, inheritance from the project, and an invitation that confers
nothing until accepted.
Handing a challenge to a team already required running that team, but
taking one away required nothing of the mover beyond being able to edit
the challenge -- so an admin of the parent project could reassign a
team's challenge to their own team, or hand it back to nobody, without
the team having any say.

A change of owning team now needs the mover to run the team it is
leaving. Clearing ownership counts as a change: without that, the rule
would be a formality, since anyone could clear first and then set.

A challenge nobody owns is unaffected, and can still be given to any
team the user runs.
requireChallengeOwnership, requireProjectOwnership and validateOwnerTeam
all read as though ownership of the team were the bar. It never was: each
resolved to isUserTeamManager, so an owner, an admin and a manager all
passed and only a plain member was turned away. Three wrappers around one
check, each named for a rule none of them applied.

They collapse into requireTeamManager, which says the bar out loud, and
the controllers' helpers become validateTeamAssignment. No behaviour
changes -- the same people could do the same things before this commit.
Access now resolves through Permission.effectiveRole, which asks two
things the old checks never did: which teams are attached to the thing
being reached for, and what the user does in them. PermissionsSpec mocks
its world, and those two services were never stubbed, so both answered
null and every write and admin case died with a NullPointerException
where it expected to be refused.

They answer "no teams involved" now, which is what those specs are about
-- the grants a person holds directly.
@sonarqubecloud

Copy link
Copy Markdown

@CollinBeczak
CollinBeczak merged commit 51a6f5c into main Sep 16, 2026
9 checks passed
@CollinBeczak
CollinBeczak deleted the project-owner-team branch September 16, 2026 19:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant