Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions admin/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ func (s *Service) InitOrganizationBilling(ctx context.Context, org *database.Org
BillingCustomerID: org.BillingCustomerID,
PaymentCustomerID: org.PaymentCustomerID,
BillingEmail: org.BillingEmail,
BillingPortalAdmin: org.BillingPortalAdmin,
BillingPlanName: org.BillingPlanName,
BillingPlanDisplayName: org.BillingPlanDisplayName,
CreatedByUserID: org.CreatedByUserID,
Expand Down Expand Up @@ -174,6 +175,7 @@ func (s *Service) RepairOrganizationBilling(ctx context.Context, org *database.O
BillingCustomerID: org.BillingCustomerID,
PaymentCustomerID: org.PaymentCustomerID,
BillingEmail: org.BillingEmail,
BillingPortalAdmin: org.BillingPortalAdmin,
BillingPlanName: org.BillingPlanName,
BillingPlanDisplayName: org.BillingPlanDisplayName,
CreatedByUserID: org.CreatedByUserID,
Expand Down Expand Up @@ -239,6 +241,7 @@ func (s *Service) RepairOrganizationBilling(ctx context.Context, org *database.O
BillingCustomerID: org.BillingCustomerID,
PaymentCustomerID: org.PaymentCustomerID,
BillingEmail: org.BillingEmail,
BillingPortalAdmin: org.BillingPortalAdmin,
BillingPlanName: org.BillingPlanName,
BillingPlanDisplayName: org.BillingPlanDisplayName,
CreatedByUserID: org.CreatedByUserID,
Expand Down Expand Up @@ -335,6 +338,7 @@ func (s *Service) StartCreditTrial(ctx context.Context, org *database.Organizati
BillingCustomerID: org.BillingCustomerID,
PaymentCustomerID: org.PaymentCustomerID,
BillingEmail: org.BillingEmail,
BillingPortalAdmin: org.BillingPortalAdmin,
BillingPlanName: &plan.Name,
BillingPlanDisplayName: &plan.DisplayName,
CreatedByUserID: org.CreatedByUserID,
Expand Down Expand Up @@ -440,6 +444,7 @@ func (s *Service) StartTrial(ctx context.Context, org *database.Organization) (*
BillingCustomerID: org.BillingCustomerID,
PaymentCustomerID: org.PaymentCustomerID,
BillingEmail: org.BillingEmail,
BillingPortalAdmin: org.BillingPortalAdmin,
BillingPlanName: &plan.Name,
BillingPlanDisplayName: &plan.DisplayName,
CreatedByUserID: org.CreatedByUserID,
Expand Down
4 changes: 4 additions & 0 deletions admin/database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ type DB interface {
FindOrganizationByName(ctx context.Context, name string) (*Organization, error)
FindOrganizationByCustomDomain(ctx context.Context, domain string) (*Organization, error)
CheckOrganizationHasPublicProjects(ctx context.Context, orgID string) (bool, error)
CheckUserIsBillingPortalAdmin(ctx context.Context, orgID, userID string) (bool, error)
InsertOrganization(ctx context.Context, opts *InsertOrganizationOptions) (*Organization, error)
DeleteOrganization(ctx context.Context, name string) error
UpdateOrganization(ctx context.Context, id string, opts *UpdateOrganizationOptions) (*Organization, error)
Expand Down Expand Up @@ -399,6 +400,7 @@ type Organization struct {
BillingCustomerID string `db:"billing_customer_id"`
PaymentCustomerID string `db:"payment_customer_id"`
BillingEmail string `db:"billing_email"`
BillingPortalAdmin string `db:"billing_portal_admin"`
BillingPlanName *string `db:"billing_plan_name"`
BillingPlanDisplayName *string `db:"billing_plan_display_name"`
CreatedByUserID *string `db:"created_by_user_id"`
Expand All @@ -425,6 +427,7 @@ type InsertOrganizationOptions struct {
BillingCustomerID string
PaymentCustomerID string
BillingEmail string
BillingPortalAdmin string
CreatedByUserID *string
}

Expand All @@ -449,6 +452,7 @@ type UpdateOrganizationOptions struct {
BillingCustomerID string
PaymentCustomerID string
BillingEmail string
BillingPortalAdmin string
BillingPlanName *string
BillingPlanDisplayName *string
CreatedByUserID *string
Expand Down
3 changes: 3 additions & 0 deletions admin/database/postgres/migrations/0096.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE orgs ADD COLUMN billing_portal_admin TEXT NOT NULL DEFAULT '';

UPDATE orgs SET billing_portal_admin = billing_email;
20 changes: 15 additions & 5 deletions admin/database/postgres/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,15 +124,25 @@ func (c *connection) CheckOrganizationHasPublicProjects(ctx context.Context, org
return res, nil
}

func (c *connection) CheckUserIsBillingPortalAdmin(ctx context.Context, orgID, userID string) (bool, error) {
var res bool
err := c.getDB(ctx).QueryRowxContext(ctx,
"SELECT EXISTS (SELECT 1 FROM orgs o JOIN users u ON u.id=$2 WHERE o.id=$1 AND o.billing_portal_admin<>'' AND lower(o.billing_portal_admin)=lower(u.email))", orgID, userID).Scan(&res)
if err != nil {
return false, parseErr("check", err)
}
return res, nil
}

func (c *connection) InsertOrganization(ctx context.Context, opts *database.InsertOrganizationOptions) (*database.Organization, error) {
if err := database.Validate(opts); err != nil {
return nil, err
}

res := &database.Organization{}
err := c.getDB(ctx).QueryRowxContext(ctx, `INSERT INTO orgs(name, display_name, description, logo_asset_id, logo_dark_asset_id, favicon_asset_id, thumbnail_asset_id, custom_domain, default_project_role_id, quota_projects, quota_deployments, quota_slots_total, quota_slots_per_deployment, quota_outstanding_invites, quota_storage_limit_bytes_per_deployment, billing_customer_id, payment_customer_id, billing_email, created_by_user_id, quota_seats)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) RETURNING *`,
opts.Name, opts.DisplayName, opts.Description, opts.LogoAssetID, opts.LogoDarkAssetID, opts.FaviconAssetID, opts.ThumbnailAssetID, opts.CustomDomain, opts.DefaultProjectRoleID, opts.QuotaProjects, opts.QuotaDeployments, opts.QuotaSlotsTotal, opts.QuotaSlotsPerDeployment, opts.QuotaOutstandingInvites, opts.QuotaStorageLimitBytesPerDeployment, opts.BillingCustomerID, opts.PaymentCustomerID, opts.BillingEmail, opts.CreatedByUserID, opts.QuotaSeats).StructScan(res)
err := c.getDB(ctx).QueryRowxContext(ctx, `INSERT INTO orgs(name, display_name, description, logo_asset_id, logo_dark_asset_id, favicon_asset_id, thumbnail_asset_id, custom_domain, default_project_role_id, quota_projects, quota_deployments, quota_slots_total, quota_slots_per_deployment, quota_outstanding_invites, quota_storage_limit_bytes_per_deployment, billing_customer_id, payment_customer_id, billing_email, billing_portal_admin, created_by_user_id, quota_seats)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) RETURNING *`,
opts.Name, opts.DisplayName, opts.Description, opts.LogoAssetID, opts.LogoDarkAssetID, opts.FaviconAssetID, opts.ThumbnailAssetID, opts.CustomDomain, opts.DefaultProjectRoleID, opts.QuotaProjects, opts.QuotaDeployments, opts.QuotaSlotsTotal, opts.QuotaSlotsPerDeployment, opts.QuotaOutstandingInvites, opts.QuotaStorageLimitBytesPerDeployment, opts.BillingCustomerID, opts.PaymentCustomerID, opts.BillingEmail, opts.BillingPortalAdmin, opts.CreatedByUserID, opts.QuotaSeats).StructScan(res)
if err != nil {
return nil, parseErr("org", err)
}
Expand All @@ -151,8 +161,8 @@ func (c *connection) UpdateOrganization(ctx context.Context, id string, opts *da

res := &database.Organization{}
err := c.getDB(ctx).QueryRowxContext(ctx,
`UPDATE orgs SET name=$1, display_name=$2, description=$3, logo_asset_id=$4, logo_dark_asset_id=$5, favicon_asset_id=$6, thumbnail_asset_id=$7, custom_domain=$8, default_project_role_id=$9, quota_projects=$10, quota_deployments=$11, quota_slots_total=$12, quota_slots_per_deployment=$13, quota_outstanding_invites=$14, quota_storage_limit_bytes_per_deployment=$15, billing_customer_id=$16, payment_customer_id=$17, billing_email=$18, created_by_user_id=$19, billing_plan_name=$20, billing_plan_display_name=$21, quota_seats=$22, updated_on=now() WHERE id=$23 RETURNING *`,
opts.Name, opts.DisplayName, opts.Description, opts.LogoAssetID, opts.LogoDarkAssetID, opts.FaviconAssetID, opts.ThumbnailAssetID, opts.CustomDomain, opts.DefaultProjectRoleID, opts.QuotaProjects, opts.QuotaDeployments, opts.QuotaSlotsTotal, opts.QuotaSlotsPerDeployment, opts.QuotaOutstandingInvites, opts.QuotaStorageLimitBytesPerDeployment, opts.BillingCustomerID, opts.PaymentCustomerID, opts.BillingEmail, opts.CreatedByUserID, opts.BillingPlanName, opts.BillingPlanDisplayName, opts.QuotaSeats, id).StructScan(res)
`UPDATE orgs SET name=$1, display_name=$2, description=$3, logo_asset_id=$4, logo_dark_asset_id=$5, favicon_asset_id=$6, thumbnail_asset_id=$7, custom_domain=$8, default_project_role_id=$9, quota_projects=$10, quota_deployments=$11, quota_slots_total=$12, quota_slots_per_deployment=$13, quota_outstanding_invites=$14, quota_storage_limit_bytes_per_deployment=$15, billing_customer_id=$16, payment_customer_id=$17, billing_email=$18, billing_portal_admin=$19, created_by_user_id=$20, billing_plan_name=$21, billing_plan_display_name=$22, quota_seats=$23, updated_on=now() WHERE id=$24 RETURNING *`,
opts.Name, opts.DisplayName, opts.Description, opts.LogoAssetID, opts.LogoDarkAssetID, opts.FaviconAssetID, opts.ThumbnailAssetID, opts.CustomDomain, opts.DefaultProjectRoleID, opts.QuotaProjects, opts.QuotaDeployments, opts.QuotaSlotsTotal, opts.QuotaSlotsPerDeployment, opts.QuotaOutstandingInvites, opts.QuotaStorageLimitBytesPerDeployment, opts.BillingCustomerID, opts.PaymentCustomerID, opts.BillingEmail, opts.BillingPortalAdmin, opts.CreatedByUserID, opts.BillingPlanName, opts.BillingPlanDisplayName, opts.QuotaSeats, id).StructScan(res)
if err != nil {
return nil, parseErr("org", err)
}
Expand Down
49 changes: 49 additions & 0 deletions admin/database/postgres/postgres_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func TestPostgres(t *testing.T) {
defer func() { require.NoError(t, db.Close()) }()

t.Run("TestOrganizations", func(t *testing.T) { testOrganizations(t, db) })
t.Run("TestBillingPortalAdmin", func(t *testing.T) { testBillingPortalAdmin(t, db) })
t.Run("TestOrgsWithPagination", func(t *testing.T) { testOrgsWithPagination(t, db) })
t.Run("TestProjects", func(t *testing.T) { testProjects(t, db) })
t.Run("TestProjectsWithAnnotations", func(t *testing.T) { testProjectsWithAnnotations(t, db) })
Expand Down Expand Up @@ -162,6 +163,54 @@ func testOrganizations(t *testing.T, db database.DB) {
require.Nil(t, org)
}

func testBillingPortalAdmin(t *testing.T, db database.DB) {
ctx := context.Background()

user, err := db.InsertUser(ctx, &database.InsertUserOptions{Email: "accounting@rilldata.com"})
require.NoError(t, err)

org, err := db.InsertOrganization(ctx, &database.InsertOrganizationOptions{
Name: randomName(),
BillingEmail: "admin@rilldata.com",
BillingPortalAdmin: "billing@rilldata.com",
})
require.NoError(t, err)
require.Equal(t, "billing@rilldata.com", org.BillingPortalAdmin)

// The user's email does not match the billing portal admin.
ok, err := db.CheckUserIsBillingPortalAdmin(ctx, org.ID, user.ID)
require.NoError(t, err)
require.False(t, ok)

// The match is case-insensitive.
org, err = db.UpdateOrganization(ctx, org.ID, &database.UpdateOrganizationOptions{
Name: org.Name,
BillingEmail: org.BillingEmail,
BillingPortalAdmin: "Accounting@RillData.com",
})
require.NoError(t, err)
require.Equal(t, "Accounting@RillData.com", org.BillingPortalAdmin)

ok, err = db.CheckUserIsBillingPortalAdmin(ctx, org.ID, user.ID)
require.NoError(t, err)
require.True(t, ok)

// An empty billing portal admin matches no one.
org, err = db.UpdateOrganization(ctx, org.ID, &database.UpdateOrganizationOptions{
Name: org.Name,
BillingEmail: org.BillingEmail,
})
require.NoError(t, err)
require.Equal(t, "", org.BillingPortalAdmin)

ok, err = db.CheckUserIsBillingPortalAdmin(ctx, org.ID, user.ID)
require.NoError(t, err)
require.False(t, ok)

require.NoError(t, db.DeleteOrganization(ctx, org.Name))
require.NoError(t, db.DeleteUser(ctx, user.ID))
}

func testOrgsWithPagination(t *testing.T, db database.DB) {
ctx := context.Background()

Expand Down
1 change: 1 addition & 0 deletions admin/jobs/river/biller_event_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ func (w *PlanChangedWorker) Work(ctx context.Context, job *river.Job[PlanChanged
BillingCustomerID: org.BillingCustomerID,
PaymentCustomerID: org.PaymentCustomerID,
BillingEmail: org.BillingEmail,
BillingPortalAdmin: org.BillingPortalAdmin,
BillingPlanName: &planName,
BillingPlanDisplayName: &planDisplayName,
CreatedByUserID: org.CreatedByUserID,
Expand Down
1 change: 1 addition & 0 deletions admin/jobs/river/subscription_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ func (w *SubscriptionCancellationCheckWorker) Work(ctx context.Context, job *riv
BillingCustomerID: org.BillingCustomerID,
PaymentCustomerID: org.PaymentCustomerID,
BillingEmail: org.BillingEmail,
BillingPortalAdmin: org.BillingPortalAdmin,
BillingPlanName: org.BillingPlanName,
BillingPlanDisplayName: org.BillingPlanDisplayName,
CreatedByUserID: org.CreatedByUserID,
Expand Down
1 change: 1 addition & 0 deletions admin/jobs/river/trial_checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ func (w *TrialGracePeriodCheckWorker) Work(ctx context.Context, job *river.Job[T
BillingCustomerID: org.BillingCustomerID,
PaymentCustomerID: org.PaymentCustomerID,
BillingEmail: org.BillingEmail,
BillingPortalAdmin: org.BillingPortalAdmin,
BillingPlanName: org.BillingPlanName,
BillingPlanDisplayName: org.BillingPlanDisplayName,
CreatedByUserID: org.CreatedByUserID,
Expand Down
16 changes: 16 additions & 0 deletions admin/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ func (s *Service) OrganizationPermissionsForUser(ctx context.Context, orgID, use
}
}

// If the user's email matches the org's billing portal admin, they get access to the org's billing (and nothing else).
if !composite.ManageOrgBilling {
ok, err := s.DB.CheckUserIsBillingPortalAdmin(ctx, orgID, userID)
if err != nil {
return nil, err
}
if ok {
composite.ManageOrgBilling = true
composite.ReadOrg = true
}
}

return composite, nil
}

Expand Down Expand Up @@ -60,6 +72,7 @@ func (s *Service) OrganizationPermissionsForService(ctx context.Context, orgID,
ReadOrgMembers: role.ReadOrgMembers,
ManageOrgMembers: role.ManageOrgMembers,
ManageOrgAdmins: role.ManageOrgAdmins,
ManageOrgBilling: role.ManageOrg,
}, nil
}

Expand Down Expand Up @@ -92,6 +105,7 @@ func (s *Service) OrganizationPermissionsForMagicAuthToken(ctx context.Context,
ReadOrgMembers: false,
ManageOrgMembers: false,
ManageOrgAdmins: false,
ManageOrgBilling: false,
}, nil
}

Expand Down Expand Up @@ -282,6 +296,8 @@ func UnionOrgRoles(a *adminv1.OrganizationPermissions, b *database.OrganizationR
ReadOrgMembers: a.ReadOrgMembers || b.ReadOrgMembers,
ManageOrgMembers: a.ManageOrgMembers || b.ManageOrgMembers,
ManageOrgAdmins: a.ManageOrgAdmins || b.ManageOrgAdmins,
// Org roles do not have a separate billing permission; manage_org implies it.
ManageOrgBilling: a.ManageOrgBilling || b.ManageOrg,
}
}

Expand Down
15 changes: 9 additions & 6 deletions admin/server/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func (s *Server) GetBillingSubscription(ctx context.Context, req *adminv1.GetBil

claims := auth.GetClaims(ctx)
forceAccess := claims.Superuser(ctx) && req.SuperuserForceAccess
if !claims.OrganizationPermissions(ctx, org.ID).ManageOrg && !forceAccess {
if !claims.OrganizationPermissions(ctx, org.ID).ManageOrgBilling && !forceAccess {
return nil, status.Error(codes.PermissionDenied, "not allowed to read org subscriptions")
}

Expand Down Expand Up @@ -68,7 +68,7 @@ func (s *Server) GetBillingCreditBalance(ctx context.Context, req *adminv1.GetBi

claims := auth.GetClaims(ctx)
forceAccess := claims.Superuser(ctx) && req.SuperuserForceAccess
if !claims.OrganizationPermissions(ctx, org.ID).ManageOrg && !forceAccess {
if !claims.OrganizationPermissions(ctx, org.ID).ManageOrgBilling && !forceAccess {
return nil, status.Error(codes.PermissionDenied, "not allowed to read org credit balance")
}

Expand All @@ -95,7 +95,7 @@ func (s *Server) UpdateBillingSubscription(ctx context.Context, req *adminv1.Upd

claims := auth.GetClaims(ctx)
forceAccess := claims.Superuser(ctx) && req.SuperuserForceAccess
if !claims.OrganizationPermissions(ctx, org.ID).ManageOrg && !forceAccess {
if !claims.OrganizationPermissions(ctx, org.ID).ManageOrgBilling && !forceAccess {
return nil, status.Error(codes.PermissionDenied, "not allowed to update org billing plan")
}

Expand Down Expand Up @@ -303,7 +303,7 @@ func (s *Server) CancelBillingSubscription(ctx context.Context, req *adminv1.Can

claims := auth.GetClaims(ctx)
forceAccess := claims.Superuser(ctx) && req.SuperuserForceAccess
if !claims.OrganizationPermissions(ctx, org.ID).ManageOrg && !forceAccess {
if !claims.OrganizationPermissions(ctx, org.ID).ManageOrgBilling && !forceAccess {
return nil, status.Error(codes.PermissionDenied, "not allowed to cancel org subscription")
}

Expand Down Expand Up @@ -370,7 +370,7 @@ func (s *Server) RenewBillingSubscription(ctx context.Context, req *adminv1.Rene

claims := auth.GetClaims(ctx)
forceAccess := claims.Superuser(ctx) && req.SuperuserForceAccess
if !claims.OrganizationPermissions(ctx, org.ID).ManageOrg && !forceAccess {
if !claims.OrganizationPermissions(ctx, org.ID).ManageOrgBilling && !forceAccess {
return nil, status.Error(codes.PermissionDenied, "not allowed to renew org subscription")
}

Expand Down Expand Up @@ -491,7 +491,7 @@ func (s *Server) GetPaymentsPortalURL(ctx context.Context, req *adminv1.GetPayme

claims := auth.GetClaims(ctx)
forceAccess := claims.Superuser(ctx) && req.SuperuserForceAccess
if !claims.OrganizationPermissions(ctx, org.ID).ManageOrg && !forceAccess {
if !claims.OrganizationPermissions(ctx, org.ID).ManageOrgBilling && !forceAccess {
return nil, status.Error(codes.PermissionDenied, "not allowed to manage org billing")
}

Expand Down Expand Up @@ -558,6 +558,7 @@ func (s *Server) SudoUpdateOrganizationBillingCustomer(ctx context.Context, req
BillingCustomerID: valOrDefault(req.BillingCustomerId, org.BillingCustomerID),
PaymentCustomerID: valOrDefault(req.PaymentCustomerId, org.PaymentCustomerID),
BillingEmail: org.BillingEmail,
BillingPortalAdmin: org.BillingPortalAdmin,
BillingPlanName: org.BillingPlanName,
BillingPlanDisplayName: org.BillingPlanDisplayName,
CreatedByUserID: org.CreatedByUserID,
Expand Down Expand Up @@ -1178,6 +1179,7 @@ func (s *Server) updateQuotasAndHandleBillingIssues(ctx context.Context, org *da
BillingPlanDisplayName: &sub.Plan.DisplayName,
PaymentCustomerID: org.PaymentCustomerID,
BillingEmail: org.BillingEmail,
BillingPortalAdmin: org.BillingPortalAdmin,
CreatedByUserID: org.CreatedByUserID,
})
if err != nil {
Expand Down Expand Up @@ -1269,6 +1271,7 @@ func (s *Server) getSubscriptionAndUpdateOrg(ctx context.Context, org *database.
BillingCustomerID: org.BillingCustomerID,
PaymentCustomerID: org.PaymentCustomerID,
BillingEmail: org.BillingEmail,
BillingPortalAdmin: org.BillingPortalAdmin,
BillingPlanName: &planName,
BillingPlanDisplayName: &planDisplayName,
CreatedByUserID: org.CreatedByUserID,
Expand Down
Loading
Loading