Skip to content
Open
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
17 changes: 17 additions & 0 deletions internal/api/setup/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,23 @@ func registerIAMRoutes(r *gin.Engine, handlers *Handlers, svcs *Services) {
iamGroup.DELETE("/users/:userId/policies/:policyId", handlers.IAM.DetachPolicyFromUser)
iamGroup.GET("/users/:userId/policies", handlers.IAM.GetUserPolicies)

// Group routes
iamGroup.POST("/groups", handlers.IAM.CreateGroup)
iamGroup.GET("/groups", handlers.IAM.ListGroups)
iamGroup.GET("/groups/:id", handlers.IAM.GetGroupByID)
iamGroup.PUT("/groups/:id", handlers.IAM.UpdateGroup)
iamGroup.DELETE("/groups/:id", handlers.IAM.DeleteGroup)

// Group membership
iamGroup.POST("/groups/:id/members/:userId", handlers.IAM.AddUserToGroup)
iamGroup.DELETE("/groups/:id/members/:userId", handlers.IAM.RemoveUserFromGroup)
iamGroup.GET("/groups/:id/members", handlers.IAM.GetGroupMembers)

// Group policy attachment
iamGroup.POST("/groups/:id/policies/:policyId", handlers.IAM.AttachPolicyToGroup)
iamGroup.DELETE("/groups/:id/policies/:policyId", handlers.IAM.DetachPolicyFromGroup)
iamGroup.GET("/groups/:id/policies", handlers.IAM.GetGroupPolicies)

// Service account routes
iamGroup.POST("/service-accounts", handlers.IAM.CreateServiceAccount)
iamGroup.GET("/service-accounts", handlers.IAM.ListServiceAccounts)
Expand Down
32 changes: 32 additions & 0 deletions internal/core/domain/group.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package domain

import (
"time"

"github.com/google/uuid"
)

// Group represents a collection of users for IAM policy assignment.
type Group struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

// UserGroup represents membership of a user in a group.
type UserGroup struct {
UserID uuid.UUID `json:"user_id"`
GroupID uuid.UUID `json:"group_id"`
TenantID uuid.UUID `json:"tenant_id"`
AddedAt time.Time `json:"added_at"`
}

// GroupPolicy maps a policy to a group (mirrors UserPolicy, RolePolicy pattern).
type GroupPolicy struct {
GroupID uuid.UUID `json:"group_id"`
PolicyID uuid.UUID `json:"policy_id"`
TenantID uuid.UUID `json:"tenant_id"`
}
37 changes: 37 additions & 0 deletions internal/core/ports/iam.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,25 @@ type IAMRepository interface {
AttachPolicyToServiceAccount(ctx context.Context, tenantID uuid.UUID, saID uuid.UUID, policyID uuid.UUID) error
DetachPolicyFromServiceAccount(ctx context.Context, tenantID uuid.UUID, saID uuid.UUID, policyID uuid.UUID) error
GetPoliciesForServiceAccount(ctx context.Context, tenantID uuid.UUID, saID uuid.UUID) ([]*domain.Policy, error)

// Group Management
CreateGroup(ctx context.Context, tenantID uuid.UUID, group *domain.Group) error
GetGroupByID(ctx context.Context, tenantID uuid.UUID, id uuid.UUID) (*domain.Group, error)
ListGroups(ctx context.Context, tenantID uuid.UUID) ([]*domain.Group, error)
UpdateGroup(ctx context.Context, tenantID uuid.UUID, group *domain.Group) error
DeleteGroup(ctx context.Context, tenantID uuid.UUID, id uuid.UUID) error

// Group Membership
AddUserToGroup(ctx context.Context, tenantID uuid.UUID, userID uuid.UUID, groupID uuid.UUID) error
RemoveUserFromGroup(ctx context.Context, tenantID uuid.UUID, userID uuid.UUID, groupID uuid.UUID) error
GetGroupsForUser(ctx context.Context, tenantID uuid.UUID, userID uuid.UUID) ([]*domain.Group, error)
GetUsersInGroup(ctx context.Context, tenantID uuid.UUID, groupID uuid.UUID) ([]uuid.UUID, error)

// Group Policy Assignment
AttachPolicyToGroup(ctx context.Context, tenantID uuid.UUID, groupID uuid.UUID, policyID uuid.UUID) error
DetachPolicyFromGroup(ctx context.Context, tenantID uuid.UUID, groupID uuid.UUID, policyID uuid.UUID) error
GetPoliciesForGroup(ctx context.Context, tenantID uuid.UUID, groupID uuid.UUID) ([]*domain.Policy, error)
GetGroupsForPolicy(ctx context.Context, tenantID uuid.UUID, policyID uuid.UUID) ([]*domain.Group, error)
}

// IAMService defines the business logic for IAM management.
Expand All @@ -58,6 +77,24 @@ type IAMService interface {
DetachPolicyFromServiceAccount(ctx context.Context, saID uuid.UUID, policyID uuid.UUID) error
GetPoliciesForServiceAccount(ctx context.Context, saID uuid.UUID) ([]*domain.Policy, error)

// Group Management
CreateGroup(ctx context.Context, group *domain.Group) error
GetGroupByID(ctx context.Context, id uuid.UUID) (*domain.Group, error)
ListGroups(ctx context.Context) ([]*domain.Group, error)
UpdateGroup(ctx context.Context, group *domain.Group) error
DeleteGroup(ctx context.Context, id uuid.UUID) error

// Group Membership
AddUserToGroup(ctx context.Context, userID uuid.UUID, groupID uuid.UUID) error
RemoveUserFromGroup(ctx context.Context, userID uuid.UUID, groupID uuid.UUID) error
GetGroupsForUser(ctx context.Context, userID uuid.UUID) ([]*domain.Group, error)
GetUsersInGroup(ctx context.Context, groupID uuid.UUID) ([]uuid.UUID, error)

// Group Policy Assignment
AttachPolicyToGroup(ctx context.Context, groupID uuid.UUID, policyID uuid.UUID) error
DetachPolicyFromGroup(ctx context.Context, groupID uuid.UUID, policyID uuid.UUID) error
GetPoliciesForGroup(ctx context.Context, groupID uuid.UUID) ([]*domain.Policy, error)

// SimulatePolicy evaluates what-if actions/resources against the given principal's policies.
// Returns the decision and which statement matched, for debugging.
SimulatePolicy(ctx context.Context, principal Principal, actions []string, resources []string, evalCtx map[string]interface{}) (*SimulateResult, error)
Expand Down
112 changes: 112 additions & 0 deletions internal/core/services/iam.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,118 @@ func (s *iamService) GetPoliciesForServiceAccount(ctx context.Context, saID uuid
return s.repo.GetPoliciesForServiceAccount(ctx, tenantID, saID)
}

// Group Management

func (s *iamService) CreateGroup(ctx context.Context, group *domain.Group) error {
tenantID := appcontext.TenantIDFromContext(ctx)
if group.ID == uuid.Nil {
group.ID = uuid.New()
}
group.TenantID = tenantID
if err := s.repo.CreateGroup(ctx, tenantID, group); err != nil {
return err
}
if err := s.eventSvc.RecordEvent(ctx, "IAM_GROUP_CREATE", group.ID.String(), "GROUP", map[string]interface{}{"name": group.Name}); err != nil {
s.logger.Warn("failed to record event", "action", "IAM_GROUP_CREATE", "group_id", group.ID, "error", err)
}
return nil
}

func (s *iamService) GetGroupByID(ctx context.Context, id uuid.UUID) (*domain.Group, error) {
tenantID := appcontext.TenantIDFromContext(ctx)
return s.repo.GetGroupByID(ctx, tenantID, id)
}

func (s *iamService) ListGroups(ctx context.Context) ([]*domain.Group, error) {
tenantID := appcontext.TenantIDFromContext(ctx)
return s.repo.ListGroups(ctx, tenantID)
}

func (s *iamService) UpdateGroup(ctx context.Context, group *domain.Group) error {
tenantID := appcontext.TenantIDFromContext(ctx)
if err := s.repo.UpdateGroup(ctx, tenantID, group); err != nil {
return err
}
if err := s.eventSvc.RecordEvent(ctx, "IAM_GROUP_UPDATE", group.ID.String(), "GROUP", map[string]interface{}{"name": group.Name}); err != nil {
s.logger.Warn("failed to record event", "action", "IAM_GROUP_UPDATE", "group_id", group.ID, "error", err)
}
return nil
}

func (s *iamService) DeleteGroup(ctx context.Context, id uuid.UUID) error {
tenantID := appcontext.TenantIDFromContext(ctx)
if err := s.repo.DeleteGroup(ctx, tenantID, id); err != nil {
return err
}
if err := s.eventSvc.RecordEvent(ctx, "IAM_GROUP_DELETE", id.String(), "GROUP", nil); err != nil {
s.logger.Warn("failed to record event", "action", "IAM_GROUP_DELETE", "group_id", id, "error", err)
}
return nil
}

// Group Membership

func (s *iamService) AddUserToGroup(ctx context.Context, userID uuid.UUID, groupID uuid.UUID) error {
tenantID := appcontext.TenantIDFromContext(ctx)
if err := s.repo.AddUserToGroup(ctx, tenantID, userID, groupID); err != nil {
return err
}
if err := s.auditSvc.Log(ctx, userID, "iam.add_to_group", "user", userID.String(), map[string]interface{}{"group_id": groupID}); err != nil {
s.logger.Warn("failed to log audit event", "action", "iam.add_to_group", "user_id", userID, "error", err)
}
return nil
}

func (s *iamService) RemoveUserFromGroup(ctx context.Context, userID uuid.UUID, groupID uuid.UUID) error {
tenantID := appcontext.TenantIDFromContext(ctx)
if err := s.repo.RemoveUserFromGroup(ctx, tenantID, userID, groupID); err != nil {
return err
}
if err := s.auditSvc.Log(ctx, userID, "iam.remove_from_group", "user", userID.String(), map[string]interface{}{"group_id": groupID}); err != nil {
s.logger.Warn("failed to log audit event", "action", "iam.remove_from_group", "user_id", userID, "error", err)
}
return nil
}

func (s *iamService) GetGroupsForUser(ctx context.Context, userID uuid.UUID) ([]*domain.Group, error) {
tenantID := appcontext.TenantIDFromContext(ctx)
return s.repo.GetGroupsForUser(ctx, tenantID, userID)
}

func (s *iamService) GetUsersInGroup(ctx context.Context, groupID uuid.UUID) ([]uuid.UUID, error) {
tenantID := appcontext.TenantIDFromContext(ctx)
return s.repo.GetUsersInGroup(ctx, tenantID, groupID)
}

// Group Policy Assignment

func (s *iamService) AttachPolicyToGroup(ctx context.Context, groupID uuid.UUID, policyID uuid.UUID) error {
tenantID := appcontext.TenantIDFromContext(ctx)
if err := s.repo.AttachPolicyToGroup(ctx, tenantID, groupID, policyID); err != nil {
return err
}
if err := s.eventSvc.RecordEvent(ctx, "IAM_POLICY_ATTACH_GROUP", policyID.String(), "POLICY", map[string]interface{}{"group_id": groupID.String()}); err != nil {
s.logger.Warn("failed to record event", "action", "IAM_POLICY_ATTACH_GROUP", "policy_id", policyID, "error", err)
}
return nil
}

func (s *iamService) DetachPolicyFromGroup(ctx context.Context, groupID uuid.UUID, policyID uuid.UUID) error {
tenantID := appcontext.TenantIDFromContext(ctx)
if err := s.repo.DetachPolicyFromGroup(ctx, tenantID, groupID, policyID); err != nil {
return err
}
if err := s.eventSvc.RecordEvent(ctx, "IAM_POLICY_DETACH_GROUP", policyID.String(), "POLICY", map[string]interface{}{"group_id": groupID.String()}); err != nil {
s.logger.Warn("failed to record event", "action", "IAM_POLICY_DETACH_GROUP", "policy_id", policyID, "error", err)
}
return nil
}

func (s *iamService) GetPoliciesForGroup(ctx context.Context, groupID uuid.UUID) ([]*domain.Policy, error) {
tenantID := appcontext.TenantIDFromContext(ctx)
return s.repo.GetPoliciesForGroup(ctx, tenantID, groupID)
}

func (s *iamService) SimulatePolicy(ctx context.Context, principal ports.Principal, actions []string, resources []string, evalCtx map[string]interface{}) (*ports.SimulateResult, error) {
const maxSimulatePairs = 100
if len(actions)*len(resources) > maxSimulatePairs {
Expand Down
32 changes: 32 additions & 0 deletions internal/core/services/rbac.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ func (s *rbacService) HasPermission(ctx context.Context, userID uuid.UUID, tenan
return allowed, nil
}
}

// 2c. Check group-attached policies via user's group memberships
if allowed, stop := s.checkGroupIAMPolicies(ctx, tenantID, userID, permission, resource, evalCtx); allowed || stop {
return allowed, nil
}
}
// 3. Fallback to Role-based logic
if roleName == domain.RoleAdmin {
Expand Down Expand Up @@ -188,6 +193,33 @@ func (s *rbacService) checkRoleIAMPolicies(ctx context.Context, tenantID uuid.UU
return s.evaluatePolicies(ctx, policies, permission, resource, evalCtx)
}

// checkGroupIAMPolicies evaluates IAM policies attached to groups a user belongs to.
// Returns (allowed, stop) where stop=true means decision is final.
func (s *rbacService) checkGroupIAMPolicies(ctx context.Context, tenantID, userID uuid.UUID, permission domain.Permission, resource string, evalCtx map[string]interface{}) (bool, bool) {
groups, err := s.iamRepo.GetGroupsForUser(ctx, tenantID, userID)
if err != nil {
s.logger.Error("RBAC: failed to get user groups, falling through to RBAC fallback", "user_id", userID, "tenant_id", tenantID, "error", err)
return false, false
}
if len(groups) == 0 {
return false, false
}
for _, group := range groups {
policies, err := s.iamRepo.GetPoliciesForGroup(ctx, tenantID, group.ID)
if err != nil {
s.logger.Warn("RBAC: failed to get group policies", "group_id", group.ID, "error", err)
continue
}
if len(policies) == 0 {
continue
}
if allowed, stop := s.evaluatePolicies(ctx, policies, permission, resource, evalCtx); allowed || stop {
return allowed, stop
}
}
return false, false
}

// evaluatePolicies evaluates a set of policies and returns (allowed, stop).
// stop=true means a final decision (Allow or Deny) was reached.
// If evaluation fails, returns an error via the logger and (false, false) to continue to next policy source.
Expand Down
Loading
Loading