1
0
forked from baron/baron-sso

Ory Keto ReBAC Policy & Relation Tuple Architecture

This commit is contained in:
2026-02-20 17:56:05 +09:00
parent 226a236bf2
commit 2ec2653bfb
23 changed files with 980 additions and 396 deletions

View File

@@ -29,6 +29,7 @@ type userGroupService struct {
userRepo repository.UserRepository
tenantRepo repository.TenantRepository
ketoService KetoService
outboxRepo repository.KetoOutboxRepository
kratos *KratosAdminService
}
@@ -37,6 +38,7 @@ func NewUserGroupService(
userRepo repository.UserRepository,
tenantRepo repository.TenantRepository,
keto KetoService,
outbox repository.KetoOutboxRepository,
kratos *KratosAdminService,
) UserGroupService {
return &userGroupService{
@@ -44,19 +46,55 @@ func NewUserGroupService(
userRepo: userRepo,
tenantRepo: tenantRepo,
ketoService: keto,
outboxRepo: outbox,
kratos: kratos,
}
}
func (s *userGroupService) Create(ctx context.Context, group *domain.UserGroup) error {
// [Polymorphic Tenant] Create corresponding Tenant record first
parentID := group.ParentID
if parentID == nil || *parentID == "" {
// If no parent user group, the parent is the company tenant
parentID = &group.TenantID
}
tenant := &domain.Tenant{
ID: group.ID, // Use same ID for 1:1 join
Type: domain.TenantTypeUserGroup,
ParentID: parentID,
Name: group.Name,
Slug: "ug-" + group.ID, // Temporary slug for user groups
Description: group.Description,
Status: domain.TenantStatusActive,
}
if group.ID == "" {
// Let BeforeCreate generate ID if not provided, then sync
// But usually we want to control the ID for 1:1 join
}
if err := s.tenantRepo.Create(ctx, tenant); err != nil {
slog.Error("Failed to create tenant record for user group", "error", err)
return err
}
// Update group.ID to match tenant.ID if it was generated
group.ID = tenant.ID
if err := s.repo.Create(ctx, group); err != nil {
return err
}
// Keto: UserGroup:<id>#parent_tenant@Tenant:<tid>
err := s.ketoService.CreateRelation(ctx, "UserGroup", group.ID, "parent_tenant", "Tenant:"+group.TenantID)
if err != nil {
slog.Error("Failed to create keto relation for user group", "error", err, "group_id", group.ID)
// Keto Hierarchy via Outbox: Tenant:<child_id>#parents@Tenant:<parent_id>
if s.outboxRepo != nil {
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: group.ID,
Relation: "parents",
Subject: "Tenant:" + *parentID,
Action: domain.KetoOutboxActionCreate,
})
}
return nil
@@ -77,8 +115,8 @@ func (s *userGroupService) Get(ctx context.Context, id string) (*domain.UserGrou
return nil, err
}
// Fetch members from Keto
tuples, err := s.ketoService.ListRelations(ctx, "UserGroup", group.ID, "members", "")
// Fetch members from Keto (Tenant namespace)
tuples, err := s.ketoService.ListRelations(ctx, "Tenant", group.ID, "members", "")
if err != nil {
slog.Error("Failed to fetch group members from keto", "error", err, "group_id", group.ID)
return nil, err
@@ -142,7 +180,7 @@ func (s *userGroupService) List(ctx context.Context, tenantID string) ([]domain.
// For each group, fetch member count from Keto
for i := range groups {
tuples, err := s.ketoService.ListRelations(ctx, "UserGroup", groups[i].ID, "members", "")
tuples, err := s.ketoService.ListRelations(ctx, "Tenant", groups[i].ID, "members", "")
if err == nil {
// Create dummy members just to carry the count for the JSON response
groups[i].Members = make([]domain.User, len(tuples))
@@ -153,30 +191,38 @@ func (s *userGroupService) List(ctx context.Context, tenantID string) ([]domain.
}
func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string) error {
// Keto: UserGroup:<groupID>#members@User:<userID>
err := s.ketoService.CreateRelation(ctx, "UserGroup", groupID, "members", "User:"+userID)
if err != nil {
slog.Error("Failed to sync group membership to keto", "error", err, "group", groupID, "user", userID)
return err
// Keto via Outbox: Tenant:<groupID>#members@User:<userID>
if s.outboxRepo != nil {
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: groupID,
Relation: "members",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
}
return nil
}
func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID string) error {
// Keto: Delete relation
err := s.ketoService.DeleteRelation(ctx, "UserGroup", groupID, "members", "User:"+userID)
if err != nil {
slog.Error("Failed to remove group membership from keto", "error", err, "group", groupID, "user", userID)
return err
// Keto via Outbox: Delete relation
if s.outboxRepo != nil {
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: groupID,
Relation: "members",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionDelete,
})
}
return nil
}
func (s *userGroupService) ListRoles(ctx context.Context, groupID string) ([]domain.GroupRole, error) {
// Query: namespace=Tenant, subject=UserGroup:groupID#members
subject := "UserGroup:" + groupID + "#members"
// Query: namespace=Tenant, subject=Tenant:groupID#members
subject := "Tenant:" + groupID + "#members"
tuples, err := s.ketoService.ListRelations(ctx, "Tenant", "", "", subject)
if err != nil {
slog.Error("Failed to fetch group roles from keto", "error", err, "group_id", groupID)
@@ -213,23 +259,31 @@ func (s *userGroupService) ListRoles(ctx context.Context, groupID string) ([]dom
}
func (s *userGroupService) AssignRoleToTenant(ctx context.Context, groupID, tenantID, relation string) error {
// Keto: Tenant:<tenantID>#<relation>@UserGroup:<groupID>#members
// This means all members of the group have the relation on the tenant.
subject := "UserGroup:" + groupID + "#members"
err := s.ketoService.CreateRelation(ctx, "Tenant", tenantID, relation, subject)
if err != nil {
slog.Error("Failed to assign group role to tenant in keto", "error", err, "group", groupID, "tenant", tenantID, "relation", relation)
return err
// Keto via Outbox: Tenant:<tenantID>#<relation>@Tenant:<groupID>#members
if s.outboxRepo != nil {
subject := "Tenant:" + groupID + "#members"
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: relation,
Subject: subject,
Action: domain.KetoOutboxActionCreate,
})
}
return nil
}
func (s *userGroupService) RemoveRoleFromTenant(ctx context.Context, groupID, tenantID, relation string) error {
subject := "UserGroup:" + groupID + "#members"
err := s.ketoService.DeleteRelation(ctx, "Tenant", tenantID, relation, subject)
if err != nil {
slog.Error("Failed to remove group role from tenant in keto", "error", err, "group", groupID, "tenant", tenantID, "relation", relation)
return err
// Keto via Outbox: Delete relation
if s.outboxRepo != nil {
subject := "Tenant:" + groupID + "#members"
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: relation,
Subject: subject,
Action: domain.KetoOutboxActionDelete,
})
}
return nil
}