Skip to content

Commit df18ec8

Browse files
committed
chore: fix comment
Signed-off-by: Alessandro Yuichi Okimoto <yuichijpn@gmail.com>
1 parent 358bd1b commit df18ec8

3 files changed

Lines changed: 307 additions & 12 deletions

File tree

pkg/account/api/admin_account.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ func (s *AccountService) getMyOrganizations(
418418
if accWithOrg.AccountV2.Disabled || accWithOrg.Organization.Disabled || accWithOrg.Organization.Archived {
419419
continue
420420
}
421-
// If the user is an admin or owner, no need to filter environments.
421+
// Add the organization if the account is an admin or owner.
422422
// Otherwise, we check if the account is enabled in any environment in this organization.
423423
if accWithOrg.AccountV2.OrganizationRole == accountproto.AccountV2_Role_Organization_ADMIN ||
424424
accWithOrg.AccountV2.OrganizationRole == accountproto.AccountV2_Role_Organization_OWNER {

pkg/environment/api/organization.go

Lines changed: 92 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -875,14 +875,17 @@ func (s *EnvironmentService) UpdateOrganization(
875875
req *environmentproto.UpdateOrganizationRequest,
876876
) (*environmentproto.UpdateOrganizationResponse, error) {
877877
localizer := locale.NewLocalizer(ctx)
878-
editor, err := s.checkSystemAdminRole(ctx, localizer)
878+
editor, err := s.checkOrganizationRole(ctx, req.Id, accountproto.AccountV2_Role_Organization_OWNER, localizer)
879879
if err != nil {
880-
// If not system admin, check if user is organization owner
881-
editor, err = s.checkOrganizationRole(ctx, req.Id, accountproto.AccountV2_Role_Organization_OWNER, localizer)
882-
if err != nil {
880+
return nil, err
881+
}
882+
// Additional security validations for ownership transfer
883+
if req.OwnerEmail != nil || (req.ChangeOwnerEmailCommand != nil && req.ChangeOwnerEmailCommand.OwnerEmail != "") {
884+
if err := s.validateOwnershipTransfer(ctx, req, editor, localizer); err != nil {
883885
return nil, err
884886
}
885887
}
888+
886889
commands := s.getUpdateOrganizationCommands(req)
887890
if len(commands) == 0 {
888891
return s.updateOrganizationNoCommand(ctx, req, editor, localizer)
@@ -942,8 +945,8 @@ func (s *EnvironmentService) updateOrganizationNoCommand(
942945
return err
943946
}
944947
// Set the new owner email if it changes
945-
if prevOwnerEmail != organization.OwnerEmail {
946-
newOwnerEmail = organization.OwnerEmail
948+
if prevOwnerEmail != updated.OwnerEmail {
949+
newOwnerEmail = updated.OwnerEmail
947950
}
948951
return orgStorage.UpdateOrganization(ctxWithTx, updated)
949952
})
@@ -1186,31 +1189,109 @@ func (s *EnvironmentService) updateOrganization(
11861189
return nil
11871190
}
11881191

1192+
// validateOwnershipTransfer performs additional security validations for ownership transfer
1193+
func (s *EnvironmentService) validateOwnershipTransfer(
1194+
ctx context.Context,
1195+
req *environmentproto.UpdateOrganizationRequest,
1196+
editor *eventproto.Editor,
1197+
localizer locale.Localizer,
1198+
) error {
1199+
// Get current organization to validate against
1200+
organization, err := s.orgStorage.GetOrganization(ctx, req.Id)
1201+
if err != nil {
1202+
return err
1203+
}
1204+
1205+
// Determine the new owner email being requested
1206+
var newOwnerEmail string
1207+
if req.OwnerEmail != nil {
1208+
newOwnerEmail = req.OwnerEmail.Value
1209+
} else if req.ChangeOwnerEmailCommand != nil {
1210+
newOwnerEmail = req.ChangeOwnerEmailCommand.OwnerEmail
1211+
}
1212+
1213+
// Don't allow no-op updates (setting same owner)
1214+
if newOwnerEmail == organization.OwnerEmail {
1215+
dt, err := statusNoCommand.WithDetails(&errdetails.LocalizedMessage{
1216+
Locale: localizer.GetLocale(),
1217+
Message: localizer.MustLocalizeWithTemplate(
1218+
locale.InvalidArgumentError,
1219+
"new owner email is the same as the current owner",
1220+
),
1221+
})
1222+
if err != nil {
1223+
return statusInternal.Err()
1224+
}
1225+
return dt.Err()
1226+
}
1227+
1228+
// If not system admin, ensure current user is actually the current owner
1229+
if !editor.IsAdmin && editor.Email != organization.OwnerEmail {
1230+
dt, err := statusPermissionDenied.WithDetails(&errdetails.LocalizedMessage{
1231+
Locale: localizer.GetLocale(),
1232+
Message: localizer.MustLocalize(locale.PermissionDenied),
1233+
})
1234+
if err != nil {
1235+
return statusInternal.Err()
1236+
}
1237+
return dt.Err()
1238+
}
1239+
1240+
// New owner must exist and be a member of the organization
1241+
newOwnerAccount, err := s.accountStorage.GetAccountV2(ctx, newOwnerEmail, req.Id)
1242+
if err != nil {
1243+
if errors.Is(err, v2acc.ErrAccountNotFound) {
1244+
dt, err := statusNotFound.WithDetails(&errdetails.LocalizedMessage{
1245+
Locale: localizer.GetLocale(),
1246+
Message: localizer.MustLocalizeWithTemplate(locale.NotFoundError, "new owner account not found in organization"),
1247+
})
1248+
if err != nil {
1249+
return statusInternal.Err()
1250+
}
1251+
return dt.Err()
1252+
}
1253+
return err
1254+
}
1255+
1256+
// New owner account must be enabled
1257+
if newOwnerAccount.Disabled {
1258+
dt, err := statusPermissionDenied.WithDetails(&errdetails.LocalizedMessage{
1259+
Locale: localizer.GetLocale(),
1260+
Message: localizer.MustLocalizeWithTemplate(locale.InvalidArgumentError, "new owner account is disabled"),
1261+
})
1262+
if err != nil {
1263+
return statusInternal.Err()
1264+
}
1265+
return dt.Err()
1266+
}
1267+
1268+
return nil
1269+
}
1270+
11891271
func (s *EnvironmentService) updateOwnerRole(
11901272
ctx context.Context,
11911273
organizationID, prevOwnerEmail, newOwnerEmail string,
11921274
) error {
1193-
accStorage := v2acc.NewAccountStorage(s.mysqlClient)
11941275
// Update the old owner organization role
1195-
prevOwnerAcc, err := accStorage.GetAccountV2(ctx, prevOwnerEmail, organizationID)
1276+
prevOwnerAcc, err := s.accountStorage.GetAccountV2(ctx, prevOwnerEmail, organizationID)
11961277
if err != nil {
11971278
return err
11981279
}
11991280
if err := prevOwnerAcc.ChangeOrganizationRole(accountproto.AccountV2_Role_Organization_ADMIN); err != nil {
12001281
return err
12011282
}
1202-
if err := accStorage.UpdateAccountV2(ctx, prevOwnerAcc); err != nil {
1283+
if err := s.accountStorage.UpdateAccountV2(ctx, prevOwnerAcc); err != nil {
12031284
return err
12041285
}
12051286
// Update the new owner organization role
1206-
newOwnerAcc, err := accStorage.GetAccountV2(ctx, newOwnerEmail, organizationID)
1287+
newOwnerAcc, err := s.accountStorage.GetAccountV2(ctx, newOwnerEmail, organizationID)
12071288
if err != nil {
12081289
return err
12091290
}
12101291
if err := newOwnerAcc.ChangeOrganizationRole(accountproto.AccountV2_Role_Organization_OWNER); err != nil {
12111292
return err
12121293
}
1213-
if err := accStorage.UpdateAccountV2(ctx, newOwnerAcc); err != nil {
1294+
if err := s.accountStorage.UpdateAccountV2(ctx, newOwnerAcc); err != nil {
12141295
return err
12151296
}
12161297
return nil

pkg/environment/api/organization_test.go

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import (
1414
gstatus "google.golang.org/grpc/status"
1515
"google.golang.org/protobuf/types/known/wrapperspb"
1616

17+
acmock "github.com/bucketeer-io/bucketeer/pkg/account/client/mock"
18+
accountdomain "github.com/bucketeer-io/bucketeer/pkg/account/domain"
19+
v2as "github.com/bucketeer-io/bucketeer/pkg/account/storage/v2"
1720
accstoragemock "github.com/bucketeer-io/bucketeer/pkg/account/storage/v2/mock"
1821
"github.com/bucketeer-io/bucketeer/pkg/environment/domain"
1922
v2es "github.com/bucketeer-io/bucketeer/pkg/environment/storage/v2"
@@ -22,6 +25,7 @@ import (
2225
publishermock "github.com/bucketeer-io/bucketeer/pkg/pubsub/publisher/mock"
2326
"github.com/bucketeer-io/bucketeer/pkg/storage/v2/mysql"
2427
mysqlmock "github.com/bucketeer-io/bucketeer/pkg/storage/v2/mysql/mock"
28+
accountproto "github.com/bucketeer-io/bucketeer/proto/account"
2529
proto "github.com/bucketeer-io/bucketeer/proto/environment"
2630
)
2731

@@ -1264,3 +1268,213 @@ func TestEnvironmentService_CreateDemoOrganization(t *testing.T) {
12641268
})
12651269
}
12661270
}
1271+
1272+
func TestValidateOwnershipTransfer(t *testing.T) {
1273+
t.Parallel()
1274+
mockController := gomock.NewController(t)
1275+
defer mockController.Finish()
1276+
1277+
// Create context with system admin token for most tests
1278+
ctxAdmin := createContextWithToken(t)
1279+
ctxAdmin = metadata.NewIncomingContext(ctxAdmin, metadata.MD{
1280+
"accept-language": []string{"ja"},
1281+
})
1282+
1283+
// Create context with non-admin token for ownership validation tests
1284+
ctxOwner := createContextWithTokenRoleUnassigned(t)
1285+
ctxOwner = metadata.NewIncomingContext(ctxOwner, metadata.MD{
1286+
"accept-language": []string{"ja"},
1287+
})
1288+
1289+
localizer := locale.NewLocalizer(ctxAdmin)
1290+
createError := func(status *gstatus.Status, msg string) error {
1291+
st, err := status.WithDetails(&errdetails.LocalizedMessage{
1292+
Locale: localizer.GetLocale(),
1293+
Message: msg,
1294+
})
1295+
require.NoError(t, err)
1296+
return st.Err()
1297+
}
1298+
1299+
patterns := []struct {
1300+
desc string
1301+
ctx context.Context
1302+
setup func(*EnvironmentService)
1303+
req *proto.UpdateOrganizationRequest
1304+
expectedErr error
1305+
}{
1306+
{
1307+
desc: "success: no ownership transfer (name change only)",
1308+
ctx: ctxAdmin,
1309+
setup: func(s *EnvironmentService) {
1310+
s.mysqlClient.(*mysqlmock.MockClient).EXPECT().RunInTransactionV2(
1311+
gomock.Any(), gomock.Any(),
1312+
).Return(nil)
1313+
s.publisher.(*publishermock.MockPublisher).EXPECT().Publish(gomock.Any(), gomock.Any()).Return(nil)
1314+
},
1315+
req: &proto.UpdateOrganizationRequest{
1316+
Id: "org-1",
1317+
Name: wrapperspb.String("New Organization Name"),
1318+
},
1319+
expectedErr: nil,
1320+
},
1321+
{
1322+
desc: "err: no-op ownership transfer (same owner email)",
1323+
ctx: ctxAdmin,
1324+
setup: func(s *EnvironmentService) {
1325+
s.orgStorage.(*storagemock.MockOrganizationStorage).EXPECT().GetOrganization(
1326+
gomock.Any(), "org-1",
1327+
).Return(&domain.Organization{
1328+
Organization: &proto.Organization{
1329+
Id: "org-1",
1330+
OwnerEmail: "current-owner@example.com",
1331+
},
1332+
}, nil)
1333+
},
1334+
req: &proto.UpdateOrganizationRequest{
1335+
Id: "org-1",
1336+
OwnerEmail: wrapperspb.String("current-owner@example.com"),
1337+
},
1338+
expectedErr: createError(statusNoCommand, localizer.MustLocalizeWithTemplate(
1339+
locale.InvalidArgumentError,
1340+
"new owner email is the same as the current owner",
1341+
)),
1342+
},
1343+
{
1344+
desc: "err: non-owner trying to transfer ownership",
1345+
ctx: ctxOwner,
1346+
setup: func(s *EnvironmentService) {
1347+
s.accountClient.(*acmock.MockClient).EXPECT().GetAccountV2(
1348+
gomock.Any(), gomock.Any(),
1349+
).Return(&accountproto.GetAccountV2Response{
1350+
Account: &accountproto.AccountV2{
1351+
Email: "email",
1352+
OrganizationRole: accountproto.AccountV2_Role_Organization_OWNER,
1353+
},
1354+
}, nil)
1355+
s.orgStorage.(*storagemock.MockOrganizationStorage).EXPECT().GetOrganization(
1356+
gomock.Any(), "org-1",
1357+
).Return(&domain.Organization{
1358+
Organization: &proto.Organization{
1359+
Id: "org-1",
1360+
OwnerEmail: "current-owner@example.com", // Different from token email
1361+
},
1362+
}, nil)
1363+
},
1364+
req: &proto.UpdateOrganizationRequest{
1365+
Id: "org-1",
1366+
OwnerEmail: wrapperspb.String("new-owner@example.com"),
1367+
},
1368+
expectedErr: createError(statusPermissionDenied, localizer.MustLocalize(locale.PermissionDenied)),
1369+
},
1370+
{
1371+
desc: "err: new owner account not found",
1372+
ctx: ctxAdmin,
1373+
setup: func(s *EnvironmentService) {
1374+
s.orgStorage.(*storagemock.MockOrganizationStorage).EXPECT().GetOrganization(
1375+
gomock.Any(), "org-1",
1376+
).Return(&domain.Organization{
1377+
Organization: &proto.Organization{
1378+
Id: "org-1",
1379+
OwnerEmail: "current-owner@example.com",
1380+
},
1381+
}, nil)
1382+
s.accountStorage.(*accstoragemock.MockAccountStorage).EXPECT().GetAccountV2(
1383+
gomock.Any(), "new-owner@example.com", "org-1",
1384+
).Return(nil, v2as.ErrAccountNotFound)
1385+
},
1386+
req: &proto.UpdateOrganizationRequest{
1387+
Id: "org-1",
1388+
OwnerEmail: wrapperspb.String("new-owner@example.com"),
1389+
},
1390+
expectedErr: createError(statusNotFound, localizer.MustLocalizeWithTemplate(locale.NotFoundError, "new owner account not found in organization")),
1391+
},
1392+
{
1393+
desc: "err: new owner account is disabled",
1394+
ctx: ctxAdmin,
1395+
setup: func(s *EnvironmentService) {
1396+
s.orgStorage.(*storagemock.MockOrganizationStorage).EXPECT().GetOrganization(
1397+
gomock.Any(), "org-1",
1398+
).Return(&domain.Organization{
1399+
Organization: &proto.Organization{
1400+
Id: "org-1",
1401+
OwnerEmail: "current-owner@example.com",
1402+
},
1403+
}, nil)
1404+
s.accountStorage.(*accstoragemock.MockAccountStorage).EXPECT().GetAccountV2(
1405+
gomock.Any(), "new-owner@example.com", "org-1",
1406+
).Return(&accountdomain.AccountV2{
1407+
AccountV2: &accountproto.AccountV2{
1408+
Email: "new-owner@example.com",
1409+
Disabled: true,
1410+
},
1411+
}, nil)
1412+
},
1413+
req: &proto.UpdateOrganizationRequest{
1414+
Id: "org-1",
1415+
OwnerEmail: wrapperspb.String("new-owner@example.com"),
1416+
},
1417+
expectedErr: createError(statusPermissionDenied, localizer.MustLocalizeWithTemplate(locale.InvalidArgumentError, "new owner account is disabled")),
1418+
},
1419+
{
1420+
desc: "success: valid ownership transfer passes validation",
1421+
ctx: ctxAdmin,
1422+
setup: func(s *EnvironmentService) {
1423+
// Mock validation phase - validateOwnershipTransfer
1424+
s.orgStorage.(*storagemock.MockOrganizationStorage).EXPECT().GetOrganization(
1425+
gomock.Any(), "org-1",
1426+
).Return(&domain.Organization{
1427+
Organization: &proto.Organization{
1428+
Id: "org-1",
1429+
OwnerEmail: "current-owner@example.com",
1430+
},
1431+
}, nil)
1432+
s.accountStorage.(*accstoragemock.MockAccountStorage).EXPECT().GetAccountV2(
1433+
gomock.Any(), "new-owner@example.com", "org-1",
1434+
).Return(&accountdomain.AccountV2{
1435+
AccountV2: &accountproto.AccountV2{
1436+
Email: "new-owner@example.com",
1437+
Disabled: false,
1438+
},
1439+
}, nil)
1440+
1441+
// Mock transaction execution (simplified)
1442+
s.mysqlClient.(*mysqlmock.MockClient).EXPECT().RunInTransactionV2(
1443+
gomock.Any(), gomock.Any(),
1444+
).Return(nil)
1445+
s.publisher.(*publishermock.MockPublisher).EXPECT().Publish(gomock.Any(), gomock.Any()).Return(nil)
1446+
1447+
// Mock updateOwnerRole calls (these happen after transaction)
1448+
s.accountStorage.(*accstoragemock.MockAccountStorage).EXPECT().GetAccountV2(
1449+
gomock.Any(), "current-owner@example.com", "org-1",
1450+
).Return(&accountdomain.AccountV2{
1451+
AccountV2: &accountproto.AccountV2{Email: "current-owner@example.com"},
1452+
}, nil).AnyTimes()
1453+
s.accountStorage.(*accstoragemock.MockAccountStorage).EXPECT().UpdateAccountV2(
1454+
gomock.Any(), gomock.Any(),
1455+
).Return(nil).AnyTimes()
1456+
s.accountStorage.(*accstoragemock.MockAccountStorage).EXPECT().GetAccountV2(
1457+
gomock.Any(), "new-owner@example.com", "org-1",
1458+
).Return(&accountdomain.AccountV2{
1459+
AccountV2: &accountproto.AccountV2{Email: "new-owner@example.com"},
1460+
}, nil).AnyTimes()
1461+
},
1462+
req: &proto.UpdateOrganizationRequest{
1463+
Id: "org-1",
1464+
OwnerEmail: wrapperspb.String("new-owner@example.com"),
1465+
},
1466+
expectedErr: nil,
1467+
},
1468+
}
1469+
1470+
for _, p := range patterns {
1471+
t.Run(p.desc, func(t *testing.T) {
1472+
service := newEnvironmentService(t, mockController, nil)
1473+
if p.setup != nil {
1474+
p.setup(service)
1475+
}
1476+
_, err := service.UpdateOrganization(p.ctx, p.req)
1477+
assert.Equal(t, p.expectedErr, err)
1478+
})
1479+
}
1480+
}

0 commit comments

Comments
 (0)