Gate private collaboration expansion
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
09c93d295d9ec1276084d9dce16f546ee919a994- Parents
-
88200be - Tree
178d41c
09c93d2
09c93d295d9ec1276084d9dce16f546ee919a99488200be
178d41cinternal/entitlements/private_collaboration.gomodified@@ -40,6 +40,24 @@ type PrivateCollaborationCheck struct { | ||
| 40 | 40 | Reason Reason |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | +type PrivateCollaborationLimitError struct { | |
| 44 | + Check PrivateCollaborationCheck | |
| 45 | +} | |
| 46 | + | |
| 47 | +func (e *PrivateCollaborationLimitError) Error() string { | |
| 48 | + if e == nil { | |
| 49 | + return ErrPrivateCollaborationLimitExceeded.Error() | |
| 50 | + } | |
| 51 | + if msg := e.Check.Message(); msg != "" { | |
| 52 | + return msg | |
| 53 | + } | |
| 54 | + return ErrPrivateCollaborationLimitExceeded.Error() | |
| 55 | +} | |
| 56 | + | |
| 57 | +func (e *PrivateCollaborationLimitError) Unwrap() error { | |
| 58 | + return ErrPrivateCollaborationLimitExceeded | |
| 59 | +} | |
| 60 | + | |
| 43 | 61 | func PrivateCollaborationUsageForOrg(ctx context.Context, deps Deps, orgID int64) (PrivateCollaborationUsage, error) { |
| 44 | 62 | usage, _, err := privateCollaborationUsageWithIDs(ctx, deps, orgID) |
| 45 | 63 | return usage, err |
@@ -198,7 +216,7 @@ func (c PrivateCollaborationCheck) Err() error { | ||
| 198 | 216 | if c.Allowed { |
| 199 | 217 | return nil |
| 200 | 218 | } |
| 201 | - return ErrPrivateCollaborationLimitExceeded | |
| 219 | + return &PrivateCollaborationLimitError{Check: c} | |
| 202 | 220 | } |
| 203 | 221 | |
| 204 | 222 | func (c PrivateCollaborationCheck) Message() string { |
internal/entitlements/private_collaboration_test.gomodified@@ -4,6 +4,7 @@ package entitlements_test | ||
| 4 | 4 | |
| 5 | 5 | import ( |
| 6 | 6 | "context" |
| 7 | + "errors" | |
| 7 | 8 | "strings" |
| 8 | 9 | "testing" |
| 9 | 10 | "time" |
@@ -88,7 +89,7 @@ func TestPrivateCollaborationExpansionEnforcesFreeLimitAndTeamUnlimited(t *testi | ||
| 88 | 89 | if err != nil { |
| 89 | 90 | t.Fatalf("blocked expansion: %v", err) |
| 90 | 91 | } |
| 91 | - if check.Allowed || check.WouldUse != 4 || check.Err() != entitlements.ErrPrivateCollaborationLimitExceeded { | |
| 92 | + if check.Allowed || check.WouldUse != 4 || !errors.Is(check.Err(), entitlements.ErrPrivateCollaborationLimitExceeded) { | |
| 92 | 93 | t.Fatalf("three-user free expansion check = %+v, want blocked", check) |
| 93 | 94 | } |
| 94 | 95 | if !strings.Contains(check.Message(), "up to 3 private collaborators") { |
internal/orgs/invitations.gomodified@@ -14,6 +14,7 @@ import ( | ||
| 14 | 14 | |
| 15 | 15 | "github.com/tenseleyFlow/shithub/internal/auth/email" |
| 16 | 16 | "github.com/tenseleyFlow/shithub/internal/auth/token" |
| 17 | + "github.com/tenseleyFlow/shithub/internal/entitlements" | |
| 17 | 18 | orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" |
| 18 | 19 | usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc" |
| 19 | 20 | ) |
@@ -96,6 +97,20 @@ func Invite(ctx context.Context, deps Deps, p InviteParams) (InviteResult, error | ||
| 96 | 97 | } else if !errors.Is(err, pgx.ErrNoRows) { |
| 97 | 98 | return InviteResult{}, err |
| 98 | 99 | } |
| 100 | + if role == orgsdb.OrgRoleOwner { | |
| 101 | + var check entitlements.PrivateCollaborationCheck | |
| 102 | + if targetUserID.Valid { | |
| 103 | + check, err = entitlements.CheckOrgOwnerPrivateCollaboration(ctx, entitlements.Deps{Pool: deps.Pool}, p.OrgID, targetUserID.Int64) | |
| 104 | + } else { | |
| 105 | + check, err = entitlements.CheckPrivateInvitationSlot(ctx, entitlements.Deps{Pool: deps.Pool}, p.OrgID) | |
| 106 | + } | |
| 107 | + if err != nil { | |
| 108 | + return InviteResult{}, err | |
| 109 | + } | |
| 110 | + if err := check.Err(); err != nil { | |
| 111 | + return InviteResult{}, err | |
| 112 | + } | |
| 113 | + } | |
| 99 | 114 | |
| 100 | 115 | tokEnc, tokHash, err := token.New() |
| 101 | 116 | if err != nil { |
@@ -186,6 +201,15 @@ func AcceptInvitation(ctx context.Context, deps Deps, inv orgsdb.OrgInvitation, | ||
| 186 | 201 | return ErrUnauthorizedAcceptor |
| 187 | 202 | } |
| 188 | 203 | } |
| 204 | + if inv.Role == orgsdb.OrgRoleOwner { | |
| 205 | + check, err := entitlements.CheckOrgOwnerPrivateCollaboration(ctx, entitlements.Deps{Pool: deps.Pool}, inv.OrgID, acceptorUserID) | |
| 206 | + if err != nil { | |
| 207 | + return err | |
| 208 | + } | |
| 209 | + if err := check.Err(); err != nil { | |
| 210 | + return err | |
| 211 | + } | |
| 212 | + } | |
| 189 | 213 | |
| 190 | 214 | tx, err := deps.Pool.Begin(ctx) |
| 191 | 215 | if err != nil { |
internal/orgs/members.gomodified@@ -10,6 +10,7 @@ import ( | ||
| 10 | 10 | "github.com/jackc/pgx/v5" |
| 11 | 11 | "github.com/jackc/pgx/v5/pgtype" |
| 12 | 12 | |
| 13 | + "github.com/tenseleyFlow/shithub/internal/entitlements" | |
| 13 | 14 | orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" |
| 14 | 15 | ) |
| 15 | 16 | |
@@ -26,6 +27,15 @@ func AddMember(ctx context.Context, deps Deps, orgID, userID, invitedByUserID in | ||
| 26 | 27 | if err != nil { |
| 27 | 28 | return err |
| 28 | 29 | } |
| 30 | + if r == orgsdb.OrgRoleOwner { | |
| 31 | + check, err := entitlements.CheckOrgOwnerPrivateCollaboration(ctx, entitlements.Deps{Pool: deps.Pool}, orgID, userID) | |
| 32 | + if err != nil { | |
| 33 | + return err | |
| 34 | + } | |
| 35 | + if err := check.Err(); err != nil { | |
| 36 | + return err | |
| 37 | + } | |
| 38 | + } | |
| 29 | 39 | tx, err := deps.Pool.Begin(ctx) |
| 30 | 40 | if err != nil { |
| 31 | 41 | return err |
@@ -83,6 +93,15 @@ func ChangeRole(ctx context.Context, deps Deps, orgID, userID int64, role string | ||
| 83 | 93 | return ErrLastOwner |
| 84 | 94 | } |
| 85 | 95 | } |
| 96 | + if current.Role != orgsdb.OrgRoleOwner && r == orgsdb.OrgRoleOwner { | |
| 97 | + check, err := entitlements.CheckOrgOwnerPrivateCollaboration(ctx, entitlements.Deps{Pool: deps.Pool}, orgID, userID) | |
| 98 | + if err != nil { | |
| 99 | + return err | |
| 100 | + } | |
| 101 | + if err := check.Err(); err != nil { | |
| 102 | + return err | |
| 103 | + } | |
| 104 | + } | |
| 86 | 105 | return q.ChangeOrgMemberRole(ctx, deps.Pool, orgsdb.ChangeOrgMemberRoleParams{ |
| 87 | 106 | OrgID: orgID, UserID: userID, Role: r, |
| 88 | 107 | }) |
internal/orgs/private_collaboration_test.goadded@@ -0,0 +1,102 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | |
| 3 | +package orgs_test | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "context" | |
| 7 | + "errors" | |
| 8 | + "testing" | |
| 9 | + | |
| 10 | + "github.com/jackc/pgx/v5/pgtype" | |
| 11 | + | |
| 12 | + "github.com/tenseleyFlow/shithub/internal/entitlements" | |
| 13 | + "github.com/tenseleyFlow/shithub/internal/orgs" | |
| 14 | + reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" | |
| 15 | +) | |
| 16 | + | |
| 17 | +func TestOwnerExpansionRespectsPrivateCollaborationLimit(t *testing.T) { | |
| 18 | + pool, deps, alice := setup(t) | |
| 19 | + ctx := context.Background() | |
| 20 | + org, err := orgs.Create(ctx, deps, orgs.CreateParams{Slug: "acme", CreatedByUserID: alice}) | |
| 21 | + if err != nil { | |
| 22 | + t.Fatalf("create org: %v", err) | |
| 23 | + } | |
| 24 | + repo := mustOrgRepo(t, pool, org.ID, "secret", "private") | |
| 25 | + insertDirectCollab(t, pool, repo.ID, mustUser(t, pool, "bob")) | |
| 26 | + insertDirectCollab(t, pool, repo.ID, mustUser(t, pool, "carol")) | |
| 27 | + | |
| 28 | + dave := mustUser(t, pool, "dave") | |
| 29 | + if err := orgs.AddMember(ctx, deps, org.ID, dave, alice, "owner"); !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) { | |
| 30 | + t.Fatalf("AddMember owner err=%v, want private collaboration limit", err) | |
| 31 | + } | |
| 32 | + if err := orgs.AddMember(ctx, deps, org.ID, dave, alice, "member"); err != nil { | |
| 33 | + t.Fatalf("plain member add should not expand private collaboration: %v", err) | |
| 34 | + } | |
| 35 | + if err := orgs.ChangeRole(ctx, deps, org.ID, dave, "owner"); !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) { | |
| 36 | + t.Fatalf("ChangeRole owner err=%v, want private collaboration limit", err) | |
| 37 | + } | |
| 38 | +} | |
| 39 | + | |
| 40 | +func TestTeamExpansionRespectsPrivateCollaborationLimit(t *testing.T) { | |
| 41 | + pool, deps, alice := setup(t) | |
| 42 | + ctx := context.Background() | |
| 43 | + org, err := orgs.Create(ctx, deps, orgs.CreateParams{Slug: "acme", CreatedByUserID: alice}) | |
| 44 | + if err != nil { | |
| 45 | + t.Fatalf("create org: %v", err) | |
| 46 | + } | |
| 47 | + repo := mustOrgRepo(t, pool, org.ID, "secret", "private") | |
| 48 | + insertDirectCollab(t, pool, repo.ID, mustUser(t, pool, "bob")) | |
| 49 | + insertDirectCollab(t, pool, repo.ID, mustUser(t, pool, "carol")) | |
| 50 | + | |
| 51 | + team, err := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{OrgID: org.ID, Slug: "security", CreatedByUserID: alice}) | |
| 52 | + if err != nil { | |
| 53 | + t.Fatalf("create team: %v", err) | |
| 54 | + } | |
| 55 | + if err := orgs.GrantTeamRepoAccess(ctx, deps, team.ID, repo.ID, alice, "read"); err != nil { | |
| 56 | + t.Fatalf("empty-team private grant should not expand private collaboration: %v", err) | |
| 57 | + } | |
| 58 | + dave := mustUser(t, pool, "dave") | |
| 59 | + if err := orgs.AddTeamMember(ctx, deps, team.ID, dave, alice, "member"); !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) { | |
| 60 | + t.Fatalf("AddTeamMember err=%v, want private collaboration limit", err) | |
| 61 | + } | |
| 62 | + | |
| 63 | + team2, err := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{OrgID: org.ID, Slug: "ops", CreatedByUserID: alice}) | |
| 64 | + if err != nil { | |
| 65 | + t.Fatalf("create second team: %v", err) | |
| 66 | + } | |
| 67 | + insertTeamMemberRaw(t, pool, team2.ID, dave) | |
| 68 | + if err := orgs.GrantTeamRepoAccess(ctx, deps, team2.ID, repo.ID, alice, "read"); !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) { | |
| 69 | + t.Fatalf("GrantTeamRepoAccess err=%v, want private collaboration limit", err) | |
| 70 | + } | |
| 71 | + if err := orgs.RemoveTeamMember(ctx, deps, team2.ID, dave); err != nil { | |
| 72 | + t.Fatalf("cleanup remove team member should remain allowed: %v", err) | |
| 73 | + } | |
| 74 | +} | |
| 75 | + | |
| 76 | +func mustOrgRepo(t *testing.T, db reposdb.DBTX, orgID int64, name, visibility string) reposdb.Repo { | |
| 77 | + t.Helper() | |
| 78 | + repo, err := reposdb.New().CreateRepo(context.Background(), db, reposdb.CreateRepoParams{ | |
| 79 | + OwnerOrgID: pgtype.Int8{Int64: orgID, Valid: true}, | |
| 80 | + Name: name, | |
| 81 | + Visibility: reposdb.RepoVisibility(visibility), | |
| 82 | + DefaultBranch: "trunk", | |
| 83 | + }) | |
| 84 | + if err != nil { | |
| 85 | + t.Fatalf("create org repo %s: %v", name, err) | |
| 86 | + } | |
| 87 | + return repo | |
| 88 | +} | |
| 89 | + | |
| 90 | +func insertDirectCollab(t *testing.T, db reposdb.DBTX, repoID, userID int64) { | |
| 91 | + t.Helper() | |
| 92 | + if _, err := db.Exec(context.Background(), `INSERT INTO repo_collaborators (repo_id, user_id, role) VALUES ($1, $2, 'read')`, repoID, userID); err != nil { | |
| 93 | + t.Fatalf("insert direct collaborator: %v", err) | |
| 94 | + } | |
| 95 | +} | |
| 96 | + | |
| 97 | +func insertTeamMemberRaw(t *testing.T, db reposdb.DBTX, teamID, userID int64) { | |
| 98 | + t.Helper() | |
| 99 | + if _, err := db.Exec(context.Background(), `INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, 'member')`, teamID, userID); err != nil { | |
| 100 | + t.Fatalf("insert team member: %v", err) | |
| 101 | + } | |
| 102 | +} | |
internal/orgs/teams.gomodified@@ -13,6 +13,7 @@ import ( | ||
| 13 | 13 | "github.com/jackc/pgx/v5/pgconn" |
| 14 | 14 | "github.com/jackc/pgx/v5/pgtype" |
| 15 | 15 | |
| 16 | + "github.com/tenseleyFlow/shithub/internal/entitlements" | |
| 16 | 17 | orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" |
| 17 | 18 | ) |
| 18 | 19 | |
@@ -110,6 +111,13 @@ func AddTeamMember(ctx context.Context, deps Deps, teamID, userID, addedByUserID | ||
| 110 | 111 | if err != nil { |
| 111 | 112 | return err |
| 112 | 113 | } |
| 114 | + check, err := entitlements.CheckTeamMemberPrivateCollaboration(ctx, entitlements.Deps{Pool: deps.Pool}, teamID, userID) | |
| 115 | + if err != nil { | |
| 116 | + return err | |
| 117 | + } | |
| 118 | + if err := check.Err(); err != nil { | |
| 119 | + return err | |
| 120 | + } | |
| 113 | 121 | return orgsdb.New().AddTeamMember(ctx, deps.Pool, orgsdb.AddTeamMemberParams{ |
| 114 | 122 | TeamID: teamID, |
| 115 | 123 | UserID: userID, |
@@ -134,6 +142,13 @@ func GrantTeamRepoAccess(ctx context.Context, deps Deps, teamID, repoID, addedBy | ||
| 134 | 142 | if err != nil { |
| 135 | 143 | return err |
| 136 | 144 | } |
| 145 | + check, err := entitlements.CheckTeamPrivateRepoGrant(ctx, entitlements.Deps{Pool: deps.Pool}, teamID, repoID) | |
| 146 | + if err != nil { | |
| 147 | + return err | |
| 148 | + } | |
| 149 | + if err := check.Err(); err != nil { | |
| 150 | + return err | |
| 151 | + } | |
| 137 | 152 | return orgsdb.New().GrantTeamRepoAccess(ctx, deps.Pool, orgsdb.GrantTeamRepoAccessParams{ |
| 138 | 153 | TeamID: teamID, |
| 139 | 154 | RepoID: repoID, |
internal/repos/create.gomodified@@ -18,6 +18,7 @@ import ( | ||
| 18 | 18 | |
| 19 | 19 | "github.com/tenseleyFlow/shithub/internal/auth/audit" |
| 20 | 20 | "github.com/tenseleyFlow/shithub/internal/auth/throttle" |
| 21 | + "github.com/tenseleyFlow/shithub/internal/entitlements" | |
| 21 | 22 | "github.com/tenseleyFlow/shithub/internal/git/hooks" |
| 22 | 23 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 23 | 24 | "github.com/tenseleyFlow/shithub/internal/issues" |
@@ -147,6 +148,15 @@ func Create(ctx context.Context, deps Deps, p Params) (Result, error) { | ||
| 147 | 148 | default: |
| 148 | 149 | return Result{}, errors.New("repos: owner is XOR — set OwnerUserID OR OwnerOrgID, not both") |
| 149 | 150 | } |
| 151 | + if p.OwnerOrgID != 0 && p.Visibility == "private" { | |
| 152 | + check, err := entitlements.CheckPrivateRepositoryCreation(ctx, entitlements.Deps{Pool: deps.Pool}, p.OwnerOrgID) | |
| 153 | + if err != nil { | |
| 154 | + return Result{}, err | |
| 155 | + } | |
| 156 | + if err := check.Err(); err != nil { | |
| 157 | + return Result{}, err | |
| 158 | + } | |
| 159 | + } | |
| 150 | 160 | |
| 151 | 161 | // Rate-limit per actor (NOT per owner) so a user can't bypass the |
| 152 | 162 | // per-account cap by spreading creates across orgs they manage. |
internal/repos/create_test.gomodified@@ -20,6 +20,7 @@ import ( | ||
| 20 | 20 | |
| 21 | 21 | "github.com/tenseleyFlow/shithub/internal/auth/audit" |
| 22 | 22 | "github.com/tenseleyFlow/shithub/internal/auth/throttle" |
| 23 | + "github.com/tenseleyFlow/shithub/internal/entitlements" | |
| 23 | 24 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 24 | 25 | "github.com/tenseleyFlow/shithub/internal/orgs" |
| 25 | 26 | "github.com/tenseleyFlow/shithub/internal/repos" |
@@ -81,6 +82,19 @@ func setupCreateEnv(t *testing.T) (*pgxpool.Pool, repos.Deps, int64, string, str | ||
| 81 | 82 | return pool, deps, user.ID, user.Username, root |
| 82 | 83 | } |
| 83 | 84 | |
| 85 | +func mustCreateRepoUser(t *testing.T, db usersdb.DBTX, username string) usersdb.User { | |
| 86 | + t.Helper() | |
| 87 | + user, err := usersdb.New().CreateUser(context.Background(), db, usersdb.CreateUserParams{ | |
| 88 | + Username: username, | |
| 89 | + DisplayName: username, | |
| 90 | + PasswordHash: fixtureHash, | |
| 91 | + }) | |
| 92 | + if err != nil { | |
| 93 | + t.Fatalf("CreateUser %s: %v", username, err) | |
| 94 | + } | |
| 95 | + return user | |
| 96 | +} | |
| 97 | + | |
| 84 | 98 | func TestCreate_EmptyRepo(t *testing.T) { |
| 85 | 99 | t.Parallel() |
| 86 | 100 | _, deps, uid, uname, root := setupCreateEnv(t) |
@@ -116,6 +130,35 @@ func TestCreate_EmptyRepo(t *testing.T) { | ||
| 116 | 130 | } |
| 117 | 131 | } |
| 118 | 132 | |
| 133 | +func TestCreate_PrivateOrgRepoRespectsCollaborationLimit(t *testing.T) { | |
| 134 | + t.Parallel() | |
| 135 | + pool, deps, uid, _, _ := setupCreateEnv(t) | |
| 136 | + ctx := context.Background() | |
| 137 | + org, err := orgs.Create(ctx, orgs.Deps{Pool: pool}, orgs.CreateParams{ | |
| 138 | + Slug: "acme", | |
| 139 | + CreatedByUserID: uid, | |
| 140 | + }) | |
| 141 | + if err != nil { | |
| 142 | + t.Fatalf("create org: %v", err) | |
| 143 | + } | |
| 144 | + for _, name := range []string{"owner2", "owner3", "owner4"} { | |
| 145 | + user := mustCreateRepoUser(t, pool, name) | |
| 146 | + if _, err := pool.Exec(ctx, `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'owner')`, org.ID, user.ID); err != nil { | |
| 147 | + t.Fatalf("insert owner: %v", err) | |
| 148 | + } | |
| 149 | + } | |
| 150 | + _, err = repos.Create(ctx, deps, repos.Params{ | |
| 151 | + OwnerOrgID: org.ID, | |
| 152 | + OwnerSlug: string(org.Slug), | |
| 153 | + ActorUserID: uid, | |
| 154 | + Name: "secret", | |
| 155 | + Visibility: "private", | |
| 156 | + }) | |
| 157 | + if !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) { | |
| 158 | + t.Fatalf("Create private org repo err=%v, want private collaboration limit", err) | |
| 159 | + } | |
| 160 | +} | |
| 161 | + | |
| 119 | 162 | func TestCreate_WithReadmeLicenseGitignore(t *testing.T) { |
| 120 | 163 | t.Parallel() |
| 121 | 164 | _, deps, uid, uname, _ := setupCreateEnv(t) |
internal/repos/lifecycle/lifecycle_test.gomodified@@ -14,7 +14,9 @@ import ( | ||
| 14 | 14 | |
| 15 | 15 | "github.com/tenseleyFlow/shithub/internal/auth/audit" |
| 16 | 16 | "github.com/tenseleyFlow/shithub/internal/auth/throttle" |
| 17 | + "github.com/tenseleyFlow/shithub/internal/entitlements" | |
| 17 | 18 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 19 | + "github.com/tenseleyFlow/shithub/internal/orgs" | |
| 18 | 20 | "github.com/tenseleyFlow/shithub/internal/repos" |
| 19 | 21 | "github.com/tenseleyFlow/shithub/internal/repos/lifecycle" |
| 20 | 22 | reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" |
@@ -87,6 +89,19 @@ func setup(t *testing.T) *env { | ||
| 87 | 89 | } |
| 88 | 90 | } |
| 89 | 91 | |
| 92 | +func mustLifecycleUser(t *testing.T, db usersdb.DBTX, username string) usersdb.User { | |
| 93 | + t.Helper() | |
| 94 | + user, err := usersdb.New().CreateUser(context.Background(), db, usersdb.CreateUserParams{ | |
| 95 | + Username: username, | |
| 96 | + DisplayName: username, | |
| 97 | + PasswordHash: fixtureHash, | |
| 98 | + }) | |
| 99 | + if err != nil { | |
| 100 | + t.Fatalf("CreateUser %s: %v", username, err) | |
| 101 | + } | |
| 102 | + return user | |
| 103 | +} | |
| 104 | + | |
| 90 | 105 | func TestRename_HappyPath(t *testing.T) { |
| 91 | 106 | t.Parallel() |
| 92 | 107 | env := setup(t) |
@@ -193,6 +208,37 @@ func TestSetVisibility(t *testing.T) { | ||
| 193 | 208 | } |
| 194 | 209 | } |
| 195 | 210 | |
| 211 | +func TestSetVisibilityRespectsPrivateCollaborationLimit(t *testing.T) { | |
| 212 | + t.Parallel() | |
| 213 | + env := setup(t) | |
| 214 | + ctx := context.Background() | |
| 215 | + org, err := orgs.Create(ctx, orgs.Deps{Pool: env.deps.Pool}, orgs.CreateParams{ | |
| 216 | + Slug: "acme", | |
| 217 | + CreatedByUserID: env.alice.ID, | |
| 218 | + }) | |
| 219 | + if err != nil { | |
| 220 | + t.Fatalf("create org: %v", err) | |
| 221 | + } | |
| 222 | + repo, err := reposdb.New().CreateRepo(ctx, env.deps.Pool, reposdb.CreateRepoParams{ | |
| 223 | + OwnerOrgID: pgtype.Int8{Int64: org.ID, Valid: true}, | |
| 224 | + Name: "soon-private", | |
| 225 | + DefaultBranch: "trunk", | |
| 226 | + Visibility: reposdb.RepoVisibilityPublic, | |
| 227 | + }) | |
| 228 | + if err != nil { | |
| 229 | + t.Fatalf("create org repo: %v", err) | |
| 230 | + } | |
| 231 | + for _, userID := range []int64{env.bob.ID, mustLifecycleUser(t, env.deps.Pool, "carol").ID, mustLifecycleUser(t, env.deps.Pool, "dave").ID} { | |
| 232 | + if _, err := env.deps.Pool.Exec(ctx, `INSERT INTO repo_collaborators (repo_id, user_id, role) VALUES ($1, $2, 'read')`, repo.ID, userID); err != nil { | |
| 233 | + t.Fatalf("insert collaborator: %v", err) | |
| 234 | + } | |
| 235 | + } | |
| 236 | + err = lifecycle.SetVisibility(ctx, env.deps, env.alice.ID, repo.ID, "private") | |
| 237 | + if !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) { | |
| 238 | + t.Fatalf("SetVisibility err=%v, want private collaboration limit", err) | |
| 239 | + } | |
| 240 | +} | |
| 241 | + | |
| 196 | 242 | func TestSoftDeleteAndRestore(t *testing.T) { |
| 197 | 243 | t.Parallel() |
| 198 | 244 | env := setup(t) |
internal/repos/lifecycle/visibility.gomodified@@ -8,6 +8,7 @@ import ( | ||
| 8 | 8 | "fmt" |
| 9 | 9 | |
| 10 | 10 | "github.com/tenseleyFlow/shithub/internal/auth/audit" |
| 11 | + "github.com/tenseleyFlow/shithub/internal/entitlements" | |
| 11 | 12 | reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" |
| 12 | 13 | ) |
| 13 | 14 | |
@@ -35,6 +36,15 @@ func SetVisibility(ctx context.Context, deps Deps, actorUserID, repoID int64, ne | ||
| 35 | 36 | if string(repo.Visibility) == newVisibility { |
| 36 | 37 | return nil // idempotent no-op |
| 37 | 38 | } |
| 39 | + if repo.OwnerOrgID.Valid && newVisibility == "private" { | |
| 40 | + check, err := entitlements.CheckRepoPrivateVisibility(ctx, entitlements.Deps{Pool: deps.Pool}, repo.OwnerOrgID.Int64, repo.ID) | |
| 41 | + if err != nil { | |
| 42 | + return err | |
| 43 | + } | |
| 44 | + if err := check.Err(); err != nil { | |
| 45 | + return err | |
| 46 | + } | |
| 47 | + } | |
| 38 | 48 | |
| 39 | 49 | if err := rq.SetRepoVisibility(ctx, deps.Pool, reposdb.SetRepoVisibilityParams{ |
| 40 | 50 | ID: repoID, |
internal/web/handlers/api/collaborators.gomodified@@ -15,6 +15,7 @@ import ( | ||
| 15 | 15 | "github.com/tenseleyFlow/shithub/internal/auth/pat" |
| 16 | 16 | "github.com/tenseleyFlow/shithub/internal/auth/policy" |
| 17 | 17 | policydb "github.com/tenseleyFlow/shithub/internal/auth/policy/sqlc" |
| 18 | + "github.com/tenseleyFlow/shithub/internal/entitlements" | |
| 18 | 19 | usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc" |
| 19 | 20 | "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 20 | 21 | ) |
@@ -166,6 +167,16 @@ func (h *Handlers) collaboratorPut(w http.ResponseWriter, r *http.Request) { | ||
| 166 | 167 | writeAPIError(w, http.StatusUnprocessableEntity, err.Error()) |
| 167 | 168 | return |
| 168 | 169 | } |
| 170 | + check, err := entitlements.CheckDirectPrivateCollaborator(r.Context(), entitlements.Deps{Pool: h.d.Pool}, repo.ID, user.ID) | |
| 171 | + if err != nil { | |
| 172 | + h.d.Logger.ErrorContext(r.Context(), "api: private collaborator entitlement check", "error", err) | |
| 173 | + writeAPIError(w, http.StatusInternalServerError, "collaborator entitlement check failed") | |
| 174 | + return | |
| 175 | + } | |
| 176 | + if err := check.Err(); err != nil { | |
| 177 | + writeAPIError(w, http.StatusPaymentRequired, err.Error()) | |
| 178 | + return | |
| 179 | + } | |
| 169 | 180 | if err := policydb.New().UpsertCollabRole(r.Context(), h.d.Pool, policydb.UpsertCollabRoleParams{ |
| 170 | 181 | RepoID: repo.ID, |
| 171 | 182 | UserID: user.ID, |
internal/web/handlers/api/repos.gomodified@@ -15,6 +15,7 @@ import ( | ||
| 15 | 15 | |
| 16 | 16 | "github.com/tenseleyFlow/shithub/internal/auth/pat" |
| 17 | 17 | "github.com/tenseleyFlow/shithub/internal/auth/policy" |
| 18 | + "github.com/tenseleyFlow/shithub/internal/entitlements" | |
| 18 | 19 | "github.com/tenseleyFlow/shithub/internal/orgs" |
| 19 | 20 | orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" |
| 20 | 21 | "github.com/tenseleyFlow/shithub/internal/repos" |
@@ -423,6 +424,8 @@ func writeRepoCreateError(w http.ResponseWriter, err error) { | ||
| 423 | 424 | writeAPIError(w, http.StatusConflict, "name taken for owner") |
| 424 | 425 | case errors.Is(err, repos.ErrNoVerifiedEmail): |
| 425 | 426 | writeAPIError(w, http.StatusUnprocessableEntity, "actor has no verified primary email") |
| 427 | + case errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded): | |
| 428 | + writeAPIError(w, http.StatusPaymentRequired, err.Error()) | |
| 426 | 429 | default: |
| 427 | 430 | writeAPIError(w, http.StatusInternalServerError, "create failed") |
| 428 | 431 | } |
@@ -509,6 +512,10 @@ func (h *Handlers) repoPatch(w http.ResponseWriter, r *http.Request) { | ||
| 509 | 512 | ldeps := lifecycle.Deps{Pool: h.d.Pool, RepoFS: h.d.RepoFS, Audit: h.d.Audit, Logger: h.d.Logger} |
| 510 | 513 | if err := lifecycle.SetVisibility(r.Context(), ldeps, auth.UserID, repo.ID, newVis); err != nil { |
| 511 | 514 | h.d.Logger.ErrorContext(r.Context(), "api: set visibility", "error", err) |
| 515 | + if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) { | |
| 516 | + writeAPIError(w, http.StatusPaymentRequired, err.Error()) | |
| 517 | + return | |
| 518 | + } | |
| 512 | 519 | writeAPIError(w, http.StatusInternalServerError, "visibility update failed") |
| 513 | 520 | return |
| 514 | 521 | } |
internal/web/handlers/orgs/orgs.gomodified@@ -42,6 +42,7 @@ import ( | ||
| 42 | 42 | authemail "github.com/tenseleyFlow/shithub/internal/auth/email" |
| 43 | 43 | "github.com/tenseleyFlow/shithub/internal/auth/secretbox" |
| 44 | 44 | "github.com/tenseleyFlow/shithub/internal/billing/stripebilling" |
| 45 | + "github.com/tenseleyFlow/shithub/internal/entitlements" | |
| 45 | 46 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 46 | 47 | "github.com/tenseleyFlow/shithub/internal/orgs" |
| 47 | 48 | orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" |
@@ -412,11 +413,21 @@ func (h *Handlers) peoplePage(w http.ResponseWriter, r *http.Request) { | ||
| 412 | 413 | "HasQuery": query != "", |
| 413 | 414 | "IsOwner": isOwner, |
| 414 | 415 | "CanManagePeople": isOwner, |
| 416 | + "Notice": peopleNoticeMessage(r.URL.Query().Get("notice")), | |
| 415 | 417 | }); err != nil { |
| 416 | 418 | h.d.Logger.ErrorContext(r.Context(), "orgs: render", "tpl", "orgs/people", "error", err) |
| 417 | 419 | } |
| 418 | 420 | } |
| 419 | 421 | |
| 422 | +func peopleNoticeMessage(code string) string { | |
| 423 | + switch code { | |
| 424 | + case "private-collab-upgrade": | |
| 425 | + return "Free organizations can have up to 3 private collaborators. Upgrade to Team to add more private collaborators." | |
| 426 | + default: | |
| 427 | + return "" | |
| 428 | + } | |
| 429 | +} | |
| 430 | + | |
| 420 | 431 | func filterOrgMembers(members []orgsdb.ListOrgMembersRow, query string) []orgsdb.ListOrgMembersRow { |
| 421 | 432 | query = strings.ToLower(strings.TrimSpace(query)) |
| 422 | 433 | if query == "" { |
@@ -478,6 +489,10 @@ func (h *Handlers) invite(w http.ResponseWriter, r *http.Request) { | ||
| 478 | 489 | if _, err := orgs.Invite(r.Context(), h.deps(), p); err != nil { |
| 479 | 490 | h.d.Logger.WarnContext(r.Context(), "orgs: invite failed", |
| 480 | 491 | "org", org.Slug, "target", target, "error", err) |
| 492 | + if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) { | |
| 493 | + http.Redirect(w, r, "/"+org.Slug+"/people?notice=private-collab-upgrade", http.StatusSeeOther) | |
| 494 | + return | |
| 495 | + } | |
| 481 | 496 | } |
| 482 | 497 | http.Redirect(w, r, "/"+org.Slug+"/people", http.StatusSeeOther) |
| 483 | 498 | } |
@@ -530,6 +545,10 @@ func (h *Handlers) memberMutate(w http.ResponseWriter, r *http.Request, action f | ||
| 530 | 545 | if err := action(org.ID, uid); err != nil { |
| 531 | 546 | h.d.Logger.WarnContext(r.Context(), "orgs: member mutation", |
| 532 | 547 | "org", org.Slug, "user_id", uid, "error", err) |
| 548 | + if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) { | |
| 549 | + http.Redirect(w, r, "/"+org.Slug+"/people?notice=private-collab-upgrade", http.StatusSeeOther) | |
| 550 | + return | |
| 551 | + } | |
| 533 | 552 | } |
| 534 | 553 | http.Redirect(w, r, "/"+org.Slug+"/people", http.StatusSeeOther) |
| 535 | 554 | } |
@@ -591,6 +610,10 @@ func (h *Handlers) invitationAction(w http.ResponseWriter, r *http.Request, acce | ||
| 591 | 610 | if err := orgs.AcceptInvitation(r.Context(), h.deps(), inv, viewer.ID); err != nil { |
| 592 | 611 | h.d.Logger.WarnContext(r.Context(), "orgs: accept invitation", |
| 593 | 612 | "id", inv.ID, "error", err) |
| 613 | + if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) { | |
| 614 | + h.d.Render.HTTPError(w, r, http.StatusPaymentRequired, "") | |
| 615 | + return | |
| 616 | + } | |
| 594 | 617 | h.d.Render.HTTPError(w, r, http.StatusForbidden, "") |
| 595 | 618 | return |
| 596 | 619 | } |
internal/web/handlers/orgs/teams.gomodified@@ -4,6 +4,7 @@ package orgs | ||
| 4 | 4 | |
| 5 | 5 | import ( |
| 6 | 6 | "context" |
| 7 | + "errors" | |
| 7 | 8 | "net/http" |
| 8 | 9 | "net/url" |
| 9 | 10 | "strconv" |
@@ -191,6 +192,8 @@ func teamsNoticeMessage(code string) string { | ||
| 191 | 192 | return "Secret teams are read-only until Team billing is brought back into good standing." |
| 192 | 193 | case "secret-teams-enterprise": |
| 193 | 194 | return "Secret teams are unavailable for Enterprise preview organizations. Contact sales to enable them." |
| 195 | + case "private-collab-upgrade": | |
| 196 | + return "Free organizations can have up to 3 private collaborators. Upgrade to Team to add more private collaborators." | |
| 194 | 197 | default: |
| 195 | 198 | return "" |
| 196 | 199 | } |
@@ -335,7 +338,13 @@ func (h *Handlers) teamMemberAddRemove(w http.ResponseWriter, r *http.Request) { | ||
| 335 | 338 | return |
| 336 | 339 | } |
| 337 | 340 | role := r.PostFormValue("role") |
| 338 | - _ = orgs.AddTeamMember(r.Context(), h.deps(), team.ID, uid, viewer.ID, role) | |
| 341 | + if err := orgs.AddTeamMember(r.Context(), h.deps(), team.ID, uid, viewer.ID, role); err != nil { | |
| 342 | + h.d.Logger.WarnContext(r.Context(), "teams: add member", "org_id", org.ID, "team_id", team.ID, "user_id", uid, "error", err) | |
| 343 | + if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) { | |
| 344 | + http.Redirect(w, r, h.teamPath(org, team)+"?notice=private-collab-upgrade", http.StatusSeeOther) | |
| 345 | + return | |
| 346 | + } | |
| 347 | + } | |
| 339 | 348 | } |
| 340 | 349 | http.Redirect(w, r, h.teamPath(org, team), http.StatusSeeOther) |
| 341 | 350 | } |
@@ -381,8 +390,14 @@ func (h *Handlers) teamRepoGrant(w http.ResponseWriter, r *http.Request) { | ||
| 381 | 390 | http.Redirect(w, r, h.teamPath(org, team)+"?notice="+noticeCode, http.StatusSeeOther) |
| 382 | 391 | return |
| 383 | 392 | } |
| 384 | - _ = orgs.GrantTeamRepoAccess(r.Context(), h.deps(), team.ID, repoID, viewer.ID, | |
| 385 | - r.PostFormValue("role")) | |
| 393 | + if err := orgs.GrantTeamRepoAccess(r.Context(), h.deps(), team.ID, repoID, viewer.ID, | |
| 394 | + r.PostFormValue("role")); err != nil { | |
| 395 | + h.d.Logger.WarnContext(r.Context(), "teams: grant repo", "org_id", org.ID, "team_id", team.ID, "repo_id", repoID, "error", err) | |
| 396 | + if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) { | |
| 397 | + http.Redirect(w, r, h.teamPath(org, team)+"?notice=private-collab-upgrade", http.StatusSeeOther) | |
| 398 | + return | |
| 399 | + } | |
| 400 | + } | |
| 386 | 401 | } |
| 387 | 402 | http.Redirect(w, r, h.teamPath(org, team), http.StatusSeeOther) |
| 388 | 403 | } |
internal/web/handlers/repo/lifecycle.gomodified@@ -12,6 +12,7 @@ import ( | ||
| 12 | 12 | "github.com/jackc/pgx/v5/pgtype" |
| 13 | 13 | |
| 14 | 14 | "github.com/tenseleyFlow/shithub/internal/auth/policy" |
| 15 | + "github.com/tenseleyFlow/shithub/internal/entitlements" | |
| 15 | 16 | "github.com/tenseleyFlow/shithub/internal/orgs" |
| 16 | 17 | "github.com/tenseleyFlow/shithub/internal/repos/lifecycle" |
| 17 | 18 | reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" |
@@ -406,6 +407,8 @@ func (h *Handlers) lifecycleError(w http.ResponseWriter, r *http.Request, err er | ||
| 406 | 407 | http.Error(w, "transfer no longer pending", http.StatusConflict) |
| 407 | 408 | case errors.Is(err, lifecycle.ErrPastGrace): |
| 408 | 409 | http.Error(w, "soft-delete grace expired", http.StatusGone) |
| 410 | + case errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded): | |
| 411 | + http.Error(w, err.Error(), http.StatusPaymentRequired) | |
| 409 | 412 | default: |
| 410 | 413 | h.d.Logger.WarnContext(r.Context(), "lifecycle: unexpected error", "error", err) |
| 411 | 414 | http.Error(w, "internal error", http.StatusInternalServerError) |
internal/web/handlers/repo/repo.gomodified@@ -24,6 +24,7 @@ import ( | ||
| 24 | 24 | "github.com/tenseleyFlow/shithub/internal/auth/secretbox" |
| 25 | 25 | "github.com/tenseleyFlow/shithub/internal/auth/throttle" |
| 26 | 26 | checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc" |
| 27 | + "github.com/tenseleyFlow/shithub/internal/entitlements" | |
| 27 | 28 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 28 | 29 | issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc" |
| 29 | 30 | "github.com/tenseleyFlow/shithub/internal/orgs" |
@@ -559,6 +560,8 @@ func friendlyCreateError(err error) string { | ||
| 559 | 560 | return "Unknown license selection." |
| 560 | 561 | case errors.Is(err, repos.ErrUnknownGitignore): |
| 561 | 562 | return "Unknown .gitignore selection." |
| 563 | + case errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded): | |
| 564 | + return err.Error() | |
| 562 | 565 | } |
| 563 | 566 | if t, ok := isThrottled(err); ok { |
| 564 | 567 | return "You're creating repositories too quickly. Try again in " + t + "." |
internal/web/templates/orgs/people.htmlmodified@@ -28,6 +28,7 @@ | ||
| 28 | 28 | </aside> |
| 29 | 29 | |
| 30 | 30 | <div class="shithub-org-people-main"> |
| 31 | + {{ with .Notice }}<p class="shithub-flash shithub-flash-notice" role="status">{{ . }}</p>{{ end }} | |
| 31 | 32 | <div class="shithub-org-people-toolbar"> |
| 32 | 33 | <form method="GET" action="/{{ .Org.Slug }}/people" class="shithub-org-people-search" role="search"> |
| 33 | 34 | {{ octicon "search" }} |