From 2d6b8e8e436680a2187741d00bf795f6dff35307 Mon Sep 17 00:00:00 2001 From: medcl Date: Thu, 11 Jun 2026 17:48:19 +0800 Subject: [PATCH 1/3] feat: add audit event infra --- core/event/audit.go | 67 +++++++++++++++++ modules/security/orm_hooks/audit.go | 112 ++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 core/event/audit.go create mode 100644 modules/security/orm_hooks/audit.go diff --git a/core/event/audit.go b/core/event/audit.go new file mode 100644 index 000000000..5d93f72d3 --- /dev/null +++ b/core/event/audit.go @@ -0,0 +1,67 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Framework is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +/* Copyright © INFINI Ltd. All rights reserved. + * web: https://infinilabs.com + * mail: hello#infini.ltd */ + +package event + +import ( + "time" + + "infini.sh/framework/core/orm" + "infini.sh/framework/core/util" +) + +func init() { + orm.MustRegisterSchemaWithIndexName(Audit{}, "audit-logs") +} + +// Audit represents an admin-facing security audit trail entry. +// Unlike Activity (which is user/app-facing), Audit records are for +// compliance, security review, and admin forensics. +// Embeds ORMObjectBase so _system.tenant_id and _system.owner_id are +// auto-wired by ORM hooks — no manual tenant stamping needed. +type Audit struct { + orm.ORMObjectBase + Timestamp time.Time `json:"timestamp,omitempty" elastic_mapping:"timestamp:{type:date}"` + Metadata AuditMetadata `json:"metadata" elastic_mapping:"metadata:{type:object}"` + Fields util.MapStr `json:"payload,omitempty" elastic_mapping:"payload:{type:object,enabled:false}"` +} + +// AuditMetadata contains structured metadata for the audit event. +type AuditMetadata struct { + // Category groups related audit events (e.g. "security", "data", "config") + Category string `json:"category,omitempty" elastic_mapping:"category:{type:keyword}"` + // Group is the sub-system (e.g. "sharing", "auth", "rbac", "orm") + Group string `json:"group,omitempty" elastic_mapping:"group:{type:keyword}"` + // Action is what happened (e.g. "share.grant", "share.revoke", "orm.create", "login") + Action string `json:"action,omitempty" elastic_mapping:"action:{type:keyword}"` + // Outcome: "success", "denied", "error" + Outcome string `json:"outcome,omitempty" elastic_mapping:"outcome:{type:keyword}"` + // UserID is the actor who performed the action + UserID string `json:"user_id,omitempty" elastic_mapping:"user_id:{type:keyword}"` + // Labels for additional indexed metadata + Labels util.MapStr `json:"labels,omitempty" elastic_mapping:"labels:{type:object}"` +} diff --git a/modules/security/orm_hooks/audit.go b/modules/security/orm_hooks/audit.go new file mode 100644 index 000000000..656d01dab --- /dev/null +++ b/modules/security/orm_hooks/audit.go @@ -0,0 +1,112 @@ +package orm_hooks + +import ( + "strings" + "sync" + "time" + + log "github.com/cihub/seelog" + "infini.sh/framework/core/event" + "infini.sh/framework/core/orm" + "infini.sh/framework/core/security" + "infini.sh/framework/core/util" +) + +var auditSchemaRepairLocker sync.Mutex + +func isMissingAuditSchemaError(err error) bool { + if err == nil { + return false + } + + message := strings.ToLower(err.Error()) + return strings.Contains(message, "no such table") || + strings.Contains(message, "index_not_found_exception") || + strings.Contains(message, "resource_not_found_exception") +} + +func saveAuditWithSchemaRepair(ctx *orm.Context, audit *event.Audit) error { + err := orm.Save(ctx, audit) + if !isMissingAuditSchemaError(err) { + return err + } + + auditSchemaRepairLocker.Lock() + defer auditSchemaRepairLocker.Unlock() + + if initErr := orm.InitSchema(); initErr != nil { + return initErr + } + + return orm.Save(ctx, audit) +} + +func init() { + // Auto-audit: emit Audit events for all ORM write operations (Create, Update, Delete). + // Runs at low priority (9999) so it executes AFTER all business hooks. + orm.RegisterDataOperationPostHook(9999, func(ctx *orm.Context, op orm.Operation, o interface{}) (*orm.Context, interface{}, error) { + if ctx == nil { + return ctx, o, nil + } + // Skip internal/system writes that bypass permission checks (e.g., fixture seeding, migrations) + if ctx.GetBool(orm.DirectWriteWithoutPermissionCheck, false) && ctx.GetBool(orm.DirectReadWithoutPermissionCheck, false) { + return ctx, o, nil + } + + var userID string + sessionUser, _ := security.GetUserFromContext(ctx.Context) + if sessionUser != nil { + userID = sessionUser.MustGetUserID() + } + if userID == "" { + return ctx, o, nil // no user context = system operation, skip audit + } + + var resourceType, resourceID string + if obj, ok := o.(orm.Object); ok { + resourceID = obj.GetID() + } + resourceType = orm.GetIndexName(o) + + if resourceType == "" || resourceType == "audit-logs" { + return ctx, o, nil // don't audit audit records themselves + } + + var action string + switch op { + case orm.OpCreate: + action = "orm.create" + case orm.OpUpdate, orm.OpSave: + action = "orm.update" + case orm.OpDelete: + action = "orm.delete" + default: + return ctx, o, nil + } + + audit := &event.Audit{ + Timestamp: time.Now(), + Metadata: event.AuditMetadata{ + Category: "data", + Group: resourceType, + Action: action, + Outcome: "success", + UserID: userID, + }, + Fields: util.MapStr{ + "resource_type": resourceType, + "resource_id": resourceID, + }, + } + audit.SetID(util.GetUUID()) + audit.SetSystemValue(orm.OwnerIDKey, userID) + + auditCtx := orm.NewContext() + auditCtx.DirectAccess() + if err := saveAuditWithSchemaRepair(auditCtx, audit); err != nil { + log.Warnf("failed to save auto-audit: %v", err) + } + + return ctx, o, nil + }, orm.OpCreate, orm.OpUpdate, orm.OpDelete, orm.OpSave) +} From 7f3d2ad1183335db402e00bb790d5d4e89cc01c9 Mon Sep 17 00:00:00 2001 From: medcl Date: Thu, 11 Jun 2026 17:52:30 +0800 Subject: [PATCH 2/3] chore: add comments --- modules/security/orm_hooks/audit.go | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/security/orm_hooks/audit.go b/modules/security/orm_hooks/audit.go index 656d01dab..4c8cec736 100644 --- a/modules/security/orm_hooks/audit.go +++ b/modules/security/orm_hooks/audit.go @@ -25,6 +25,7 @@ func isMissingAuditSchemaError(err error) bool { strings.Contains(message, "resource_not_found_exception") } +//won't miss an audit message func saveAuditWithSchemaRepair(ctx *orm.Context, audit *event.Audit) error { err := orm.Save(ctx, audit) if !isMissingAuditSchemaError(err) { From e23fc6b22cd7bd3f6611ff409996820a90174c1e Mon Sep 17 00:00:00 2001 From: medcl Date: Thu, 11 Jun 2026 17:53:54 +0800 Subject: [PATCH 3/3] chore: draft sharing refactoring --- core/orm/orm.go | 2 + core/security/param_keys.go | 21 + modules/security/orm_hooks/hooks.go | 10 + modules/security/share/api.go | 25 +- modules/security/share/resolver.go | 120 +++ modules/security/share/service.go | 313 ++++++- .../share/service_integration_test.go | 878 ++++++++++++++++++ .../share/testdata/docker-compose.yml | 26 + .../security/share/testdata/easysearch.yml | 76 ++ modules/security/share/testdata/start.sh | 26 + modules/security/share/testdata/stop.sh | 10 + 11 files changed, 1479 insertions(+), 28 deletions(-) create mode 100644 core/security/param_keys.go create mode 100644 modules/security/share/resolver.go create mode 100644 modules/security/share/service_integration_test.go create mode 100644 modules/security/share/testdata/docker-compose.yml create mode 100644 modules/security/share/testdata/easysearch.yml create mode 100755 modules/security/share/testdata/start.sh create mode 100755 modules/security/share/testdata/stop.sh diff --git a/core/orm/orm.go b/core/orm/orm.go index d1b6844ec..00d402dbb 100755 --- a/core/orm/orm.go +++ b/core/orm/orm.go @@ -108,6 +108,8 @@ type Object interface { const OwnerIDKey = "owner_id" const TenantIDKey = "tenant_id" const TeamsIDKey = "teams_id" +const TeamIDKey = "team_id" +const ProjectIDKey = "project_id" const SystemFieldsKey = "_system" func GetSystemFieldKey(field string) string { diff --git a/core/security/param_keys.go b/core/security/param_keys.go new file mode 100644 index 000000000..0af191822 --- /dev/null +++ b/core/security/param_keys.go @@ -0,0 +1,21 @@ +/* Copyright © INFINI LTD. All rights reserved. + * Web: https://infinilabs.com + * Email: hello#infini.ltd */ + +package security + +import "infini.sh/framework/core/param" + +// Standard parameter keys for user session context. +// These keys are used with UserSessionInfo.GetString() / GetStringArray() +// since UserSessionInfo embeds param.Parameters. +const ( + ParamTenantID param.ParaKey = "tenant_id" + ParamTenantName param.ParaKey = "tenant_name" + ParamTeamID param.ParaKey = "team_id" + ParamTeamName param.ParaKey = "team_name" + ParamProjectID param.ParaKey = "project_id" + ParamProjectName param.ParaKey = "project_name" + ParamTeamIDs param.ParaKey = "team_ids" + ParamProjectIDs param.ParaKey = "project_ids" +) diff --git a/modules/security/orm_hooks/hooks.go b/modules/security/orm_hooks/hooks.go index 303f7e532..29ac7fdbf 100644 --- a/modules/security/orm_hooks/hooks.go +++ b/modules/security/orm_hooks/hooks.go @@ -509,6 +509,16 @@ func init() { bq.ShouldClauses = append(bq.ShouldClauses, orm.TermQuery(orm.GetSystemFieldKey(orm.OwnerIDKey), userID)) + // Include resources shared with user's teams. + if teamIDs, ok := sessionUser.GetStringArray(security.ParamTeamIDs); ok && len(teamIDs) > 0 { + bq.ShouldClauses = append(bq.ShouldClauses, orm.TermsQuery(orm.GetSystemFieldKey(orm.TeamIDKey), teamIDs)) + } + + // Include resources shared with user's projects. + if projectIDs, ok := sessionUser.GetStringArray(security.ParamProjectIDs); ok && len(projectIDs) > 0 { + bq.ShouldClauses = append(bq.ShouldClauses, orm.TermsQuery(orm.GetSystemFieldKey(orm.ProjectIDKey), projectIDs)) + } + if len(bq.ShouldClauses) > 1 { bq.Parameter("minimum_should_match", 1) } diff --git a/modules/security/share/api.go b/modules/security/share/api.go index 8069c7398..cd18e4c49 100644 --- a/modules/security/share/api.go +++ b/modules/security/share/api.go @@ -10,6 +10,7 @@ import ( "infini.sh/framework/core/api" httprouter "infini.sh/framework/core/api/router" "infini.sh/framework/core/orm" + "infini.sh/framework/core/rate" "infini.sh/framework/core/security" ) @@ -69,6 +70,15 @@ func (r *BulkOpResponses[T]) AddUnchanged(item *T) { } func (h APIHandler) createOrUpdateShare(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { + // Rate limit: 1 req/sec per user to prevent abuse + sessionUser := security.MustGetUserFromRequest(req) + userID := sessionUser.MustGetUserID() + limiter := rate.GetRateLimiterPerSecond("share_api", userID, 1) + if !limiter.Allow() { + h.WriteError(w, "rate limit exceeded, please try again later", http.StatusTooManyRequests) + return + } + op := ShareRequest{} h.MustDecodeJSON(req, &op) @@ -78,9 +88,6 @@ func (h APIHandler) createOrUpdateShare(w http.ResponseWriter, req *http.Request ctx := orm.NewContextWithParent(req.Context()) ctx.Refresh = orm.WaitForRefresh - sessionUser := security.MustGetUserFromContext(ctx.Context) - userID := sessionUser.MustGetUserID() - newOp := ShareRequest{} for _, v := range op.Shares { v.ResourceType = resourceType @@ -104,15 +111,21 @@ func (h APIHandler) createOrUpdateShare(w http.ResponseWriter, req *http.Request } func (h APIHandler) batchCreateOrUpdateShare(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { + // Rate limit: 1 req/sec per user to prevent abuse + sessionUser := security.MustGetUserFromRequest(req) + userID := sessionUser.MustGetUserID() + limiter := rate.GetRateLimiterPerSecond("share_batch_api", userID, 1) + if !limiter.Allow() { + h.WriteError(w, "rate limit exceeded, please try again later", http.StatusTooManyRequests) + return + } + op := ShareRequest{} h.MustDecodeJSON(req, &op) ctx := orm.NewContextWithParent(req.Context()) ctx.Refresh = orm.WaitForRefresh - sessionUser := security.MustGetUserFromContext(ctx.Context) - userID := sessionUser.MustGetUserID() - service := NewSharingService() lists, err := service.CreateOrUpdateShares(ctx, userID, &op) if err != nil { diff --git a/modules/security/share/resolver.go b/modules/security/share/resolver.go new file mode 100644 index 000000000..80c2c114a --- /dev/null +++ b/modules/security/share/resolver.go @@ -0,0 +1,120 @@ +package share + +import ( + "sync" + + "infini.sh/framework/core/elastic" + "infini.sh/framework/core/orm" + "infini.sh/framework/core/security" +) + +type ResolvedResource struct { + Exists bool + OwnerID string + Attributes map[string]string + Resource ResourceEntity +} + +type ResourceResolver func(ctx *orm.Context, resource ResourceEntity) (*ResolvedResource, error) + +type ResolvedPrincipal struct { + Exists bool + Attributes map[string]string +} + +type PrincipalResolver func(ctx *orm.Context, principalID string) (*ResolvedPrincipal, error) + +type RuntimeExtension struct { + BuildIdentityScope func(ctx *orm.Context, user *security.UserSessionInfo, record *SharingRecord) []string + ValidateResolvedResource func(ctx *orm.Context, user *security.UserSessionInfo, resource *ResolvedResource) error + ValidateResolvedPrincipal func(ctx *orm.Context, user *security.UserSessionInfo, principalType string, principalID string, principal *ResolvedPrincipal) error + PrepareShareForWrite func(ctx *orm.Context, user *security.UserSessionInfo, resource *ResolvedResource, record *SharingRecord) error + PrepareExistingShareUpdate func(ctx *orm.Context, user *security.UserSessionInfo, record *SharingRecord) error +} + +var resourceResolvers sync.Map +var principalResolvers sync.Map +var runtimeExtensions []RuntimeExtension +var runtimeExtensionsLock sync.RWMutex + +func RegisterResourceResolver(resourceType string, resolver ResourceResolver) { + resourceResolvers.Store(resourceType, resolver) +} + +func RegisterPrincipalResolver(principalType string, resolver PrincipalResolver) { + principalResolvers.Store(principalType, resolver) +} + +func RegisterRuntimeExtension(ext RuntimeExtension) { + runtimeExtensionsLock.Lock() + defer runtimeExtensionsLock.Unlock() + runtimeExtensions = append(runtimeExtensions, ext) +} + +func getResourceResolver(resourceType string) ResourceResolver { + if value, ok := resourceResolvers.Load(resourceType); ok { + if resolver, ok := value.(ResourceResolver); ok { + return resolver + } + } + return nil +} + +func getPrincipalResolver(principalType string) PrincipalResolver { + if value, ok := principalResolvers.Load(principalType); ok { + if resolver, ok := value.(PrincipalResolver); ok { + return resolver + } + } + return nil +} + +func getRuntimeExtensions() []RuntimeExtension { + runtimeExtensionsLock.RLock() + defer runtimeExtensionsLock.RUnlock() + if len(runtimeExtensions) == 0 { + return nil + } + out := make([]RuntimeExtension, len(runtimeExtensions)) + copy(out, runtimeExtensions) + return out +} + +func newResolverReadContext(parent *orm.Context) *orm.Context { + var ctx *orm.Context + if parent != nil && parent.Context != nil { + ctx = orm.NewContextWithParent(parent.Context) + ctx.Refresh = parent.Refresh + } else { + ctx = orm.NewContext() + } + ctx.DirectReadAccess() + ctx.PermissionScope(security.PermissionScopePlatform) + return ctx +} + +func defaultUserPrincipalResolver(ctx *orm.Context, principalID string) (*ResolvedPrincipal, error) { + + if _, user, err := security.GetUserByID(principalID); err == nil && user != nil { + return &ResolvedPrincipal{Exists: true}, nil + } + + lookupCtx := newResolverReadContext(ctx) + orm.WithModel(lookupCtx, &security.UserAccount{}) + + qb := orm.NewQuery() + qb.Filter(orm.TermQuery("id", principalID)) + qb.Size(1) + + docs := []security.UserAccount{} + err, _ := elastic.SearchV2WithResultItemMapper(lookupCtx, &docs, qb, nil) + if err != nil { + return &ResolvedPrincipal{Exists: false}, nil + } + + return &ResolvedPrincipal{Exists: len(docs) > 0}, nil +} + +func init() { + RegisterPrincipalResolver(security.PrincipalTypeUser, defaultUserPrincipalResolver) +} diff --git a/modules/security/share/service.go b/modules/security/share/service.go index c926bb05a..17a2d34a1 100644 --- a/modules/security/share/service.go +++ b/modules/security/share/service.go @@ -8,10 +8,12 @@ import ( "fmt" "sort" "strings" + "time" log "github.com/cihub/seelog" "infini.sh/framework/core/elastic" "infini.sh/framework/core/errors" + "infini.sh/framework/core/event" "infini.sh/framework/core/global" "infini.sh/framework/core/orm" "infini.sh/framework/core/security" @@ -125,7 +127,7 @@ func (s *SharingService) GetUserExplicitEffectivePermission(user *security.UserS for _, share := range shares { //let's double check if share.ResourceID != r.ResourceID { - panic("invalid sharing record, resource_id is not correct") + return None, errors.New("invalid sharing record, resource_id is not correct") } if share.Permission > maxPermission { maxPermission = share.Permission @@ -155,7 +157,7 @@ func (s *SharingService) BatchGetShares(ctx *orm.Context, user *security.UserSes for _, v := range req { if v.ResourceType == "" || v.ResourceID == "" { - panic("resource type can't be empty") + return nil, errors.New("resource type can't be empty") } //group resources by resource's parent path @@ -386,38 +388,296 @@ func (s *SharingService) MergeWithTeamRules(user *security.UserSessionInfo, docs type SharingResponse struct { } +func cloneShareContext(ctx *orm.Context) *orm.Context { + if ctx != nil && ctx.Context != nil { + cloned := orm.NewContextWithParent(ctx.Context) + cloned.Refresh = ctx.Refresh + cloned.PermissionScope(ctx.GetIntOrDefault(orm.PermissionCheckingScope, security.PermissionScopePlatform)) + return cloned + } + + cloned := orm.NewContext() + cloned.PermissionScope(security.PermissionScopePlatform) + return cloned +} + +func buildSharingRecordID(ctx *orm.Context, user *security.UserSessionInfo, share *SharingRecord) string { + parts := []string{} + for _, ext := range getRuntimeExtensions() { + if ext.BuildIdentityScope != nil { + parts = append(parts, ext.BuildIdentityScope(ctx, user, share)...) + } + } + parts = append(parts, + share.ResourceCategoryType, + share.ResourceCategoryID, + share.ResourceType, + share.ResourceID, + fmt.Sprintf("%t", share.ResourceIsFolder), + share.ResourceParentPath, + share.ResourceFullPath, + share.PrincipalType, + share.PrincipalID, + ) + return util.MD5digest(strings.Join(parts, "|")) +} + +func validateResolvedResource(ctx *orm.Context, user *security.UserSessionInfo, resource *ResolvedResource) error { + for _, ext := range getRuntimeExtensions() { + if ext.ValidateResolvedResource != nil { + if err := ext.ValidateResolvedResource(ctx, user, resource); err != nil { + return err + } + } + } + return nil +} + +func validateResolvedPrincipal(ctx *orm.Context, user *security.UserSessionInfo, principalType string, principalID string, principal *ResolvedPrincipal) error { + for _, ext := range getRuntimeExtensions() { + if ext.ValidateResolvedPrincipal != nil { + if err := ext.ValidateResolvedPrincipal(ctx, user, principalType, principalID, principal); err != nil { + return err + } + } + } + return nil +} + +func prepareShareForWrite(ctx *orm.Context, user *security.UserSessionInfo, resource *ResolvedResource, share *SharingRecord) error { + for _, ext := range getRuntimeExtensions() { + if ext.PrepareShareForWrite != nil { + if err := ext.PrepareShareForWrite(ctx, user, resource, share); err != nil { + return err + } + } + } + return nil +} + +func prepareExistingShareUpdate(ctx *orm.Context, user *security.UserSessionInfo, share *SharingRecord) error { + for _, ext := range getRuntimeExtensions() { + if ext.PrepareExistingShareUpdate != nil { + if err := ext.PrepareExistingShareUpdate(ctx, user, share); err != nil { + return err + } + } + } + return nil +} + +func (s *SharingService) getShareByID(ctx *orm.Context, id string) (*SharingRecord, error) { + if id == "" { + return nil, nil + } + + lookupCtx := cloneShareContext(ctx) + lookupCtx.DirectReadAccess() + lookupCtx.PermissionScope(security.PermissionScopePlatform) + + doc := &SharingRecord{} + doc.SetID(id) + exists, err := orm.GetWithSystemFields(lookupCtx, doc) + if err != nil { + if strings.Contains(err.Error(), "record not found") { + return nil, nil + } + return nil, err + } + if !exists { + return nil, nil + } + return doc, nil +} + +func (s *SharingService) resolveResource(ctx *orm.Context, resource ResourceEntity) (*ResolvedResource, error) { + resolver := getResourceResolver(resource.ResourceType) + if resolver == nil { + return nil, errors.Errorf("no resource resolver registered for resource type: %v", resource.ResourceType) + } + + resolved, err := resolver(ctx, resource) + if err != nil { + return nil, err + } + if resolved == nil || !resolved.Exists { + return nil, errors.Errorf("resource not found: %v/%v", resource.ResourceType, resource.ResourceID) + } + if resolved.Resource.ResourceType == "" { + resolved.Resource.ResourceType = resource.ResourceType + } + if resolved.Resource.ResourceID == "" { + resolved.Resource.ResourceID = resource.ResourceID + } + if resolved.Resource.ResourceParentPath == "" { + resolved.Resource.ResourceParentPath = util.NormalizeFolderPath(resource.ResourceParentPath) + } + return resolved, nil +} + +func (s *SharingService) validatePrincipal(ctx *orm.Context, user *security.UserSessionInfo, principalType string, principalID string) error { + resolver := getPrincipalResolver(principalType) + if resolver == nil { + return errors.Errorf("no principal resolver registered for principal type: %v", principalType) + } + + resolved, err := resolver(ctx, principalID) + if err != nil { + return err + } + if resolved == nil || !resolved.Exists { + return errors.Errorf("principal not found: %v/%v", principalType, principalID) + } + if err := validateResolvedPrincipal(ctx, user, principalType, principalID, resolved); err != nil { + return err + } + return nil +} + +func (s *SharingService) ensureShareManageAccess(user *security.UserSessionInfo, resource *ResolvedResource) error { + if user == nil { + return errors.New("invalid user info") + } + + userID := user.MustGetUserID() + if util.ContainsAnyInArray(security.RoleAdmin, user.Roles) { + return nil + } + if resource != nil && resource.OwnerID == userID { + return nil + } + + perm, err := s.GetUserExplicitEffectivePermission(user, resource.Resource) + if err != nil { + return err + } + if perm < Share { + return errors.Errorf("user %v is not allowed to manage shares for %v/%v", userID, resource.Resource.ResourceType, resource.Resource.ResourceID) + } + return nil +} + +func emitShareAudit(userID string, action string, record *SharingRecord) { + audit := &event.Audit{ + Timestamp: time.Now(), + Metadata: event.AuditMetadata{ + Category: "security", + Group: "sharing", + Action: action, + Outcome: "success", + UserID: userID, + }, + Fields: util.MapStr{ + "resource_type": record.ResourceType, + "resource_id": record.ResourceID, + "principal_type": record.PrincipalType, + "principal_id": record.PrincipalID, + "permission": record.Permission, + "share_id": record.ID, + }, + } + audit.SetID(util.GetUUID()) + audit.SetSystemValue(orm.OwnerIDKey, userID) + ctx := orm.NewContext() + ctx.DirectAccess() + if err := orm.Save(ctx, audit); err != nil { + log.Errorf("failed to save share audit: %v", err) + } +} + +func (s *SharingService) prepareShareForWrite(ctx *orm.Context, user *security.UserSessionInfo, share *SharingRecord, resolved *ResolvedResource, userID string) error { + share.ResourceType = resolved.Resource.ResourceType + share.ResourceID = resolved.Resource.ResourceID + share.ResourceCategoryType = resolved.Resource.ResourceCategoryType + share.ResourceCategoryID = resolved.Resource.ResourceCategoryID + + if share.ResourceParentPath == "" { + share.ResourceParentPath = resolved.Resource.ResourceParentPath + } + share.ResourceParentPath = util.NormalizeFolderPath(share.ResourceParentPath) + if share.ResourceFullPath == "" { + share.ResourceFullPath = resolved.Resource.ResourceFullPath + } + if share.ResourceIsFolder && share.ResourceFullPath != "" { + share.ResourceFullPath = util.NormalizeFolderPath(share.ResourceFullPath) + } + share.ResourceParentPathReversed = util.ReverseString(share.ResourceParentPath) + share.GrantedBy = userID + share.ID = buildSharingRecordID(ctx, user, share) + if share.GetSystemString(orm.OwnerIDKey) == "" { + share.SetSystemValue(orm.OwnerIDKey, userID) + } + return prepareShareForWrite(ctx, user, resolved, share) +} + // CreateOrUpdateShares handles sharing resources with users/groups func (s *SharingService) CreateOrUpdateShares(ctx *orm.Context, userID string, req *ShareRequest) (*BulkOpResponses[SharingRecord], error) { list := NewBulkOpResponses[SharingRecord]() + sessionUser, err := security.GetUserFromContext(ctx.Context) + if err != nil || sessionUser == nil { + return nil, errors.New("invalid user info") + } + if userID == "" { + userID = sessionUser.MustGetUserID() + } //TODO, verify these share records, check the resource paths and the resource are well and correctly aligned // Handle revokes for _, revoke := range req.Revokes { if revoke.ID != "" { - // TODO: permission check, validate current user's operation - // 1. if the resource is owned by current user - // 2. current user with `share` permission - ctx.DirectAccess() // TODO remove this line - ctx.PermissionScope(security.PermissionScopePlatform) + existingShare, err := s.getShareByID(ctx, revoke.ID) + if err != nil { + return nil, errors.Errorf("failed to get share for revoke: %v", err) + } + if existingShare == nil { + return nil, errors.Errorf("failed to revoke share: share was not found") + } + + resolvedResource, err := s.resolveResource(ctx, existingShare.ResourceEntity) + if err != nil { + return nil, errors.Errorf("failed to validate revoke resource: %v", err) + } + if err := validateResolvedResource(ctx, sessionUser, resolvedResource); err != nil { + return nil, err + } + if err := s.ensureShareManageAccess(sessionUser, resolvedResource); err != nil { + return nil, err + } - err := orm.Delete(ctx, &revoke) + deleteCtx := cloneShareContext(ctx) + deleteCtx.DirectWriteAccess() + deleteCtx.PermissionScope(security.PermissionScopePlatform) + + err = orm.Delete(deleteCtx, existingShare) if err != nil { return nil, errors.Errorf("failed to revoke share: %v", err) } - list.AddDeleted(&revoke) + list.AddDeleted(existingShare) + emitShareAudit(userID, "share.revoke", existingShare) } } // Create records for each share for _, share := range req.Shares { - share.ResourceParentPath = util.NormalizeFolderPath(share.ResourceParentPath) - if share.ResourceIsFolder { - share.ResourceFullPath = util.NormalizeFolderPath(share.ResourceFullPath) + resolvedResource, err := s.resolveResource(ctx, share.ResourceEntity) + if err != nil { + return list, errors.Errorf("failed to validate resource: %v", err) + } + if err := validateResolvedResource(ctx, sessionUser, resolvedResource); err != nil { + return list, err + } + if err := s.validatePrincipal(ctx, sessionUser, share.PrincipalType, share.PrincipalID); err != nil { + return list, errors.Errorf("failed to validate principal: %v", err) + } + if err := s.ensureShareManageAccess(sessionUser, resolvedResource); err != nil { + return list, err } - // Check if a share already exists for this combination - resourceParentPath := s.getResourcePath(ctx, share.ResourceType, share.ResourceID, share.ResourceParentPath) - existingShare, err := s.checkExistingShare(share.ResourceID, share.ResourceType, share.PrincipalID, share.PrincipalType, resourceParentPath) + if err := s.prepareShareForWrite(ctx, sessionUser, &share, resolvedResource, userID); err != nil { + return list, errors.Errorf("failed to prepare share: %v", err) + } + + existingShare, err := s.getShareByID(ctx, share.ID) if err != nil { return list, errors.Errorf("failed to check existing share: %v", err) } @@ -431,20 +691,26 @@ func (s *SharingService) CreateOrUpdateShares(ctx *orm.Context, userID string, r } // Update existing share with new permission - err := s.updateExistingShare(existingShare, share.Permission, userID) + err := s.updateExistingShare(ctx, existingShare, share.Permission, userID, sessionUser) if err != nil { return list, errors.Errorf("failed to update existing share: %v", err) } list.AddUpdated(existingShare) + emitShareAudit(userID, "share.update", existingShare) log.Debugf("Updated existing share for principal %s on resource %s (permission changed from %v to %v)", share.PrincipalID, share.ResourceID, existingShare.Permission, share.Permission) } else { - share.ResourceParentPathReversed = util.ReverseString(share.ResourceParentPath) if err := orm.Create(ctx, &share); err != nil { + existingShare, lookupErr := s.getShareByID(ctx, share.ID) + if lookupErr == nil && existingShare != nil && existingShare.Permission == share.Permission { + list.AddUnchanged(existingShare) + continue + } return list, errors.Errorf("failed to create share: %v", err) } list.AddCreated(&share) + emitShareAudit(userID, "share.grant", &share) log.Debugf("Created new share for principal %s on resource %s", share.PrincipalID, share.ResourceID) } } @@ -493,7 +759,7 @@ func GetSharingRules(user *security.UserSessionInfo, resourceType string, resour } if len(clauses) == 0 { - panic("invalid clauses, should not be empty") + return shares, errors.New("invalid clauses, should not be empty") } if resourceParentPath != "" { @@ -560,7 +826,7 @@ func GetSharingRulesV2(user *security.UserSessionInfo, resourceType string, reso } if len(clauses) == 0 { - panic("invalid clauses, should not be empty") + return shares, errors.New("invalid clauses, should not be empty") } //check current resource's parent paths @@ -890,13 +1156,16 @@ func (s *SharingService) checkExistingShare(resourceID, resourceType, principalI } // updateExistingShare updates an existing share record -func (s *SharingService) updateExistingShare(existingShare *SharingRecord, newPermission SharingPermission, grantedBy string) error { - ctx := orm.NewContext() - ctx.DirectAccess() +func (s *SharingService) updateExistingShare(parent *orm.Context, existingShare *SharingRecord, newPermission SharingPermission, grantedBy string, user *security.UserSessionInfo) error { + ctx := cloneShareContext(parent) + ctx.DirectWriteAccess() ctx.PermissionScope(security.PermissionScopePlatform) existingShare.Permission = newPermission existingShare.GrantedBy = grantedBy + if err := prepareExistingShareUpdate(ctx, user, existingShare); err != nil { + return err + } return orm.Save(ctx, existingShare) } diff --git a/modules/security/share/service_integration_test.go b/modules/security/share/service_integration_test.go new file mode 100644 index 000000000..316725dce --- /dev/null +++ b/modules/security/share/service_integration_test.go @@ -0,0 +1,878 @@ +//go:build integration + +package share_test + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "infini.sh/framework/core/elastic" + "infini.sh/framework/core/global" + "infini.sh/framework/core/model" + "infini.sh/framework/core/orm" + "infini.sh/framework/core/security" + elastic_module "infini.sh/framework/modules/elastic" + elastic_common "infini.sh/framework/modules/elastic/common" + _ "infini.sh/framework/modules/security/orm_hooks" + share "infini.sh/framework/modules/security/share" +) + +const ( + testElasticEndpoint = "https://localhost:19200" + testElasticUser = "admin" + testElasticPassword = "ShareTest_2026!" +) + +var setupOnce sync.Once +var setupErr error + +type testShareResource struct { + orm.ORMObjectBase + ResourceType string `json:"resource_type,omitempty" elastic_mapping:"resource_type:{type:keyword}"` + ResourceParentPath string `json:"resource_parent_path,omitempty" elastic_mapping:"resource_parent_path:{type:keyword}"` + ResourceFullPath string `json:"resource_full_path,omitempty" elastic_mapping:"resource_full_path:{type:keyword}"` +} + +type testSharePrincipal struct { + orm.ORMObjectBase + PrincipalType string `json:"principal_type,omitempty" elastic_mapping:"principal_type:{type:keyword}"` + Name string `json:"name,omitempty" elastic_mapping:"name:{type:keyword}"` +} + +func TestMain(m *testing.M) { + setupOnce.Do(func() { + setupErr = setupIntegrationORM() + }) + if setupErr != nil { + fmt.Fprintf(os.Stderr, "integration setup failed: %v\n", setupErr) + os.Exit(1) + } + os.Exit(m.Run()) +} + +func setupIntegrationORM() error { + esConfig := elastic.ElasticsearchConfig{ + Name: "sharing-test", + Enabled: true, + Endpoint: testElasticEndpoint, + BasicAuth: &model.BasicAuth{ + Username: testElasticUser, + Password: testElasticPassword, + }, + } + + client, err := elastic_common.InitElasticInstanceWithoutMetadata(esConfig) + if err != nil { + return err + } + + handler := elastic_module.ElasticORM{ + Client: client, + Config: elastic_common.ORMConfig{Enabled: true}, + } + orm.Register("elastic-sharing-it", &handler) + orm.MustRegisterSchemaWithIndexName(&testShareResource{}, "test-share-resources") + orm.MustRegisterSchemaWithIndexName(&testSharePrincipal{}, "test-share-principals") + share.RegisterRuntimeExtension(share.RuntimeExtension{ + BuildIdentityScope: func(ctx *orm.Context, user *security.UserSessionInfo, record *share.SharingRecord) []string { + tenantID, _ := user.GetString(orm.TenantIDKey) + if tenantID == "" { + return nil + } + return []string{tenantID} + }, + ValidateResolvedResource: func(ctx *orm.Context, user *security.UserSessionInfo, resource *share.ResolvedResource) error { + tenantID, _ := user.GetString(orm.TenantIDKey) + resourceTenantID := resource.Attributes[orm.TenantIDKey] + if tenantID != "" && resourceTenantID != "" && tenantID != resourceTenantID { + return fmt.Errorf("invalid data access") + } + return nil + }, + ValidateResolvedPrincipal: func(ctx *orm.Context, user *security.UserSessionInfo, principalType string, principalID string, principal *share.ResolvedPrincipal) error { + tenantID, _ := user.GetString(orm.TenantIDKey) + principalTenantID := principal.Attributes[orm.TenantIDKey] + if tenantID != "" && principalTenantID != "" && tenantID != principalTenantID { + return fmt.Errorf("principal belongs to a different tenant: %v/%v", principalType, principalID) + } + return nil + }, + PrepareShareForWrite: func(ctx *orm.Context, user *security.UserSessionInfo, resource *share.ResolvedResource, record *share.SharingRecord) error { + tenantID, _ := user.GetString(orm.TenantIDKey) + if tenantID != "" { + record.SetSystemValue(orm.TenantIDKey, tenantID) + } + return nil + }, + PrepareExistingShareUpdate: func(ctx *orm.Context, user *security.UserSessionInfo, record *share.SharingRecord) error { + tenantID, _ := user.GetString(orm.TenantIDKey) + if tenantID != "" { + record.SetSystemValue(orm.TenantIDKey, tenantID) + } + return nil + }, + }) + share.RegisterResourceResolver("document", resolveTestDocumentResource) + share.RegisterPrincipalResolver(security.PrincipalTypeUser, resolveTestPrincipal(security.PrincipalTypeUser)) + share.RegisterPrincipalResolver(security.PrincipalTypeTeam, resolveTestPrincipal(security.PrincipalTypeTeam)) + + for _, cb := range global.GetFuncAfterSetup() { + cb() + } + + return orm.InitSchema() +} + +func createTestSession(userID string, roles []string, tenantID string) *security.UserSessionInfo { + session := &security.UserSessionInfo{ + Provider: "test", + Login: userID, + UserID: userID, + Roles: roles, + } + session.Set(orm.TenantIDKey, tenantID) + return session +} + +func createORMContext(session *security.UserSessionInfo) *orm.Context { + ctx := orm.NewContextWithParent(security.AddUserToContext(context.Background(), session)) + ctx.Refresh = orm.WaitForRefresh + ctx.PermissionScope(security.PermissionScopePlatform) + return ctx +} + +func uniqueResourceID(prefix string) string { + return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()) +} + +func newFixtureContext() *orm.Context { + ctx := orm.NewContext() + ctx.Refresh = orm.WaitForRefresh + ctx.DirectAccess() + ctx.PermissionScope(security.PermissionScopePlatform) + return ctx +} + +func principalDocID(principalType string, principalID string) string { + return fmt.Sprintf("%s:%s", principalType, principalID) +} + +func resolveTestDocumentResource(ctx *orm.Context, resource share.ResourceEntity) (*share.ResolvedResource, error) { + lookupCtx := newFixtureContext() + if ctx != nil && ctx.Context != nil { + lookupCtx = orm.NewContextWithParent(ctx.Context) + lookupCtx.Refresh = ctx.Refresh + lookupCtx.DirectReadAccess() + lookupCtx.PermissionScope(security.PermissionScopePlatform) + } + + doc := &testShareResource{} + doc.SetID(resource.ResourceID) + exists, err := orm.GetWithSystemFields(lookupCtx, doc) + if err != nil && !strings.Contains(err.Error(), "record not found") { + return nil, err + } + + resolved := resource + if resolved.ResourceParentPath == "" { + resolved.ResourceParentPath = doc.ResourceParentPath + } + if resolved.ResourceParentPath == "" { + resolved.ResourceParentPath = "/" + } + if resolved.ResourceFullPath == "" { + resolved.ResourceFullPath = doc.ResourceFullPath + } + + return &share.ResolvedResource{ + Exists: exists, + OwnerID: doc.GetSystemString(orm.OwnerIDKey), + Attributes: map[string]string{ + orm.TenantIDKey: doc.GetSystemString(orm.TenantIDKey), + }, + Resource: resolved, + }, nil +} + +func resolveTestPrincipal(principalType string) share.PrincipalResolver { + return func(ctx *orm.Context, principalID string) (*share.ResolvedPrincipal, error) { + lookupCtx := newFixtureContext() + if ctx != nil && ctx.Context != nil { + lookupCtx = orm.NewContextWithParent(ctx.Context) + lookupCtx.Refresh = ctx.Refresh + lookupCtx.DirectReadAccess() + lookupCtx.PermissionScope(security.PermissionScopePlatform) + } + + doc := &testSharePrincipal{} + doc.SetID(principalDocID(principalType, principalID)) + exists, err := orm.GetWithSystemFields(lookupCtx, doc) + if err != nil && !strings.Contains(err.Error(), "record not found") { + return nil, err + } + + return &share.ResolvedPrincipal{ + Exists: exists, + Attributes: map[string]string{ + orm.TenantIDKey: doc.GetSystemString(orm.TenantIDKey), + }, + }, nil + } +} + +func seedTestResource(t *testing.T, owner *security.UserSessionInfo, resourceID string) { + t.Helper() + + ctx := newFixtureContext() + doc := &testShareResource{ + ResourceType: "document", + ResourceParentPath: "/", + ResourceFullPath: "/" + resourceID, + } + doc.SetID(resourceID) + doc.SetSystemValue(orm.OwnerIDKey, owner.MustGetUserID()) + doc.SetSystemValue(orm.TenantIDKey, owner.MustGetString(orm.TenantIDKey)) + require.NoError(t, orm.Save(ctx, doc)) +} + +func seedUserPrincipal(t *testing.T, principalID string, tenantID string) { + t.Helper() + + ctx := newFixtureContext() + doc := &testSharePrincipal{PrincipalType: security.PrincipalTypeUser, Name: principalID} + doc.SetID(principalDocID(security.PrincipalTypeUser, principalID)) + doc.SetSystemValue(orm.TenantIDKey, tenantID) + doc.SetSystemValue(orm.OwnerIDKey, principalID) + require.NoError(t, orm.Save(ctx, doc)) +} + +func seedTeamPrincipal(t *testing.T, principalID string, tenantID string) { + t.Helper() + + ctx := newFixtureContext() + doc := &testSharePrincipal{PrincipalType: security.PrincipalTypeTeam, Name: principalID} + doc.SetID(principalDocID(security.PrincipalTypeTeam, principalID)) + doc.SetSystemValue(orm.TenantIDKey, tenantID) + doc.SetSystemValue(orm.OwnerIDKey, principalID) + require.NoError(t, orm.Save(ctx, doc)) +} + +func getAllResourceRules(t *testing.T, resourceType, resourceID string) []share.SharingRecord { + t.Helper() + + service := share.NewSharingService() + rules, err := service.GetResourcePermissions(nil, resourceType, []string{resourceID}) + require.NoError(t, err) + return rules +} + +func TestSharing_CreateAndRead_BackgroundBasics(t *testing.T) { + require.NoError(t, setupErr) + + service := share.NewSharingService() + owner := createTestSession("owner-u1", nil, "tenant-a") + ctx := createORMContext(owner) + + resourceID := uniqueResourceID("res-basic") + seedTestResource(t, owner, resourceID) + seedUserPrincipal(t, "user-target-1", "tenant-a") + op := &share.ShareRequest{ + Shares: []share.SharingRecord{ + { + SimplifySharingRecord: share.SimplifySharingRecord{ + ResourceEntity: share.ResourceEntity{ + ResourceType: "document", + ResourceID: resourceID, + }, + PrincipalType: security.PrincipalTypeUser, + PrincipalID: "user-target-1", + Permission: share.View, + }, + }, + }, + } + + res, err := service.CreateOrUpdateShares(ctx, owner.MustGetUserID(), op) + require.NoError(t, err) + require.Len(t, res.Created, 1) + + allRules, err := service.GetResourcePermissions(nil, "document", []string{resourceID}) + require.NoError(t, err) + require.NotEmpty(t, allRules) + require.Equal(t, "user-target-1", allRules[0].PrincipalID) + require.Equal(t, security.PrincipalTypeUser, allRules[0].PrincipalType) + require.Equal(t, share.View, allRules[0].Permission) +} + +func TestSharing_NonOwnerCanShare_CurrentBehaviorRegressionGuard(t *testing.T) { + require.NoError(t, setupErr) + + service := share.NewSharingService() + owner := createTestSession("owner-share-1", nil, "tenant-a") + nonOwner := createTestSession("attacker-u1", nil, "tenant-a") + ctx := createORMContext(nonOwner) + + resourceID := uniqueResourceID("res-vuln-share") + seedTestResource(t, owner, resourceID) + seedUserPrincipal(t, "victim-user", "tenant-a") + op := &share.ShareRequest{ + Shares: []share.SharingRecord{ + { + SimplifySharingRecord: share.SimplifySharingRecord{ + ResourceEntity: share.ResourceEntity{ + ResourceType: "document", + ResourceID: resourceID, + }, + PrincipalType: security.PrincipalTypeUser, + PrincipalID: "victim-user", + Permission: share.Share, + }, + }, + }, + } + + _, err := service.CreateOrUpdateShares(ctx, nonOwner.MustGetUserID(), op) + require.Error(t, err, "non-owner sharing must be denied") +} + +func TestSharing_NonOwnerCannotRevokeShareByID(t *testing.T) { + require.NoError(t, setupErr) + + service := share.NewSharingService() + owner := createTestSession("owner-revoke-1", nil, "tenant-a") + ownerCtx := createORMContext(owner) + + resourceID := uniqueResourceID("res-revoke") + seedTestResource(t, owner, resourceID) + seedUserPrincipal(t, "target-revoke-1", "tenant-a") + createOp := &share.ShareRequest{ + Shares: []share.SharingRecord{ + { + SimplifySharingRecord: share.SimplifySharingRecord{ + ResourceEntity: share.ResourceEntity{ResourceType: "document", ResourceID: resourceID}, + PrincipalType: security.PrincipalTypeUser, + PrincipalID: "target-revoke-1", + Permission: share.View, + }, + }, + }, + } + + _, err := service.CreateOrUpdateShares(ownerCtx, owner.MustGetUserID(), createOp) + require.NoError(t, err) + + rules, err := service.GetResourcePermissions(nil, "document", []string{resourceID}) + require.NoError(t, err) + require.NotEmpty(t, rules) + + attacker := createTestSession("attacker-revoke-1", nil, "tenant-a") + attackerCtx := createORMContext(attacker) + revokeOp := &share.ShareRequest{ + Revokes: []share.SharingRecord{ + {ORMObjectBase: orm.ORMObjectBase{ID: rules[0].ID}}, + }, + } + + _, err = service.CreateOrUpdateShares(attackerCtx, attacker.MustGetUserID(), revokeOp) + require.Error(t, err, "non-owner revoke must be denied") +} + +func TestSharing_RejectsUnknownPrincipal(t *testing.T) { + require.NoError(t, setupErr) + + service := share.NewSharingService() + owner := createTestSession("owner-principal-1", nil, "tenant-a") + ctx := createORMContext(owner) + + resourceID := uniqueResourceID("res-principal") + seedTestResource(t, owner, resourceID) + op := &share.ShareRequest{ + Shares: []share.SharingRecord{ + { + SimplifySharingRecord: share.SimplifySharingRecord{ + ResourceEntity: share.ResourceEntity{ResourceType: "document", ResourceID: resourceID}, + PrincipalType: security.PrincipalTypeUser, + PrincipalID: "does-not-exist-user", + Permission: share.View, + }, + }, + }, + } + + _, err := service.CreateOrUpdateShares(ctx, owner.MustGetUserID(), op) + require.Error(t, err, "sharing must fail when principal does not exist") +} + +func TestSharing_RejectsUnknownResource(t *testing.T) { + require.NoError(t, setupErr) + + service := share.NewSharingService() + owner := createTestSession("owner-resource-1", nil, "tenant-a") + ctx := createORMContext(owner) + seedUserPrincipal(t, "target-resource-1", "tenant-a") + + op := &share.ShareRequest{ + Shares: []share.SharingRecord{ + { + SimplifySharingRecord: share.SimplifySharingRecord{ + ResourceEntity: share.ResourceEntity{ResourceType: "document", ResourceID: "non-existent-resource-id"}, + PrincipalType: security.PrincipalTypeUser, + PrincipalID: "target-resource-1", + Permission: share.View, + }, + }, + }, + } + + _, err := service.CreateOrUpdateShares(ctx, owner.MustGetUserID(), op) + require.Error(t, err, "sharing must fail for non-existent resource") +} + +// TestSharing_CreatedShareStoresTenantID moved to plugins/enterprise/managed/share_integration_test.go +// (tenant persistence is an enterprise-plugin concern) + +func TestSharing_UpdatesExistingShareWithoutCreatingDuplicate(t *testing.T) { + require.NoError(t, setupErr) + + service := share.NewSharingService() + owner := createTestSession("owner-update-1", nil, "tenant-a") + ctx := createORMContext(owner) + + resourceID := uniqueResourceID("res-update") + seedTestResource(t, owner, resourceID) + seedUserPrincipal(t, "target-update-1", "tenant-a") + createOp := &share.ShareRequest{ + Shares: []share.SharingRecord{ + { + SimplifySharingRecord: share.SimplifySharingRecord{ + ResourceEntity: share.ResourceEntity{ResourceType: "document", ResourceID: resourceID}, + PrincipalType: security.PrincipalTypeUser, + PrincipalID: "target-update-1", + Permission: share.View, + }, + }, + }, + } + + first, err := service.CreateOrUpdateShares(ctx, owner.MustGetUserID(), createOp) + require.NoError(t, err) + require.Len(t, first.Created, 1) + + updateOp := &share.ShareRequest{ + Shares: []share.SharingRecord{ + { + SimplifySharingRecord: share.SimplifySharingRecord{ + ResourceEntity: share.ResourceEntity{ResourceType: "document", ResourceID: resourceID}, + PrincipalType: security.PrincipalTypeUser, + PrincipalID: "target-update-1", + Permission: share.Edit, + }, + }, + }, + } + + second, err := service.CreateOrUpdateShares(ctx, owner.MustGetUserID(), updateOp) + require.NoError(t, err) + require.Len(t, second.Updated, 1) + + rules := getAllResourceRules(t, "document", resourceID) + require.Len(t, rules, 1) + require.Equal(t, share.Edit, rules[0].Permission) +} + +func TestSharing_MergeWithTeamRulesAddsInheritedUserRule(t *testing.T) { + require.NoError(t, setupErr) + + service := share.NewSharingService() + owner := createTestSession("owner-team-1", nil, "tenant-a") + ownerCtx := createORMContext(owner) + + resourceID := uniqueResourceID("res-team") + seedTestResource(t, owner, resourceID) + seedTeamPrincipal(t, "team-alpha", "tenant-a") + op := &share.ShareRequest{ + Shares: []share.SharingRecord{ + { + SimplifySharingRecord: share.SimplifySharingRecord{ + ResourceEntity: share.ResourceEntity{ResourceType: "document", ResourceID: resourceID}, + PrincipalType: security.PrincipalTypeTeam, + PrincipalID: "team-alpha", + PrincipalDisplayName: "Alpha Team", + Permission: share.View, + }, + }, + }, + } + + _, err := service.CreateOrUpdateShares(ownerCtx, owner.MustGetUserID(), op) + require.NoError(t, err) + + member := createTestSession("member-team-1", nil, "tenant-a") + member.Set(orm.TeamsIDKey, []string{"team-alpha"}) + ctx := createORMContext(member) + + docs, err := service.BatchGetShares(ctx, member, []share.ResourceEntity{{ + ResourceType: "document", + ResourceID: resourceID, + }}) + require.NoError(t, err) + + merged := service.MergeWithTeamRules(member, docs) + + var inherited *share.SharingRecord + for i := range merged { + if merged[i].PrincipalType == security.PrincipalTypeUser && merged[i].PrincipalID == member.MustGetUserID() && merged[i].Via == share.ViaInherit { + inherited = &merged[i] + break + } + } + + require.NotNil(t, inherited) + require.Equal(t, share.InheritedTypeTeam, inherited.InheritedType) + require.Equal(t, "team-alpha", inherited.InheritedFrom) + require.Equal(t, share.View, inherited.Permission) +} + +func TestSharing_MergeWithTeamRulesPrefersExplicitUserRule(t *testing.T) { + require.NoError(t, setupErr) + + service := share.NewSharingService() + owner := createTestSession("owner-team-2", nil, "tenant-a") + ownerCtx := createORMContext(owner) + + resourceID := uniqueResourceID("res-team-explicit") + seedTestResource(t, owner, resourceID) + seedTeamPrincipal(t, "team-beta", "tenant-a") + seedUserPrincipal(t, "member-team-2", "tenant-a") + op := &share.ShareRequest{ + Shares: []share.SharingRecord{ + { + SimplifySharingRecord: share.SimplifySharingRecord{ + ResourceEntity: share.ResourceEntity{ResourceType: "document", ResourceID: resourceID}, + PrincipalType: security.PrincipalTypeTeam, + PrincipalID: "team-beta", + Permission: share.View, + }, + }, + { + SimplifySharingRecord: share.SimplifySharingRecord{ + ResourceEntity: share.ResourceEntity{ResourceType: "document", ResourceID: resourceID}, + PrincipalType: security.PrincipalTypeUser, + PrincipalID: "member-team-2", + Permission: share.Edit, + }, + }, + }, + } + + _, err := service.CreateOrUpdateShares(ownerCtx, owner.MustGetUserID(), op) + require.NoError(t, err) + + member := createTestSession("member-team-2", nil, "tenant-a") + member.Set(orm.TeamsIDKey, []string{"team-beta"}) + ctx := createORMContext(member) + + docs, err := service.BatchGetShares(ctx, member, []share.ResourceEntity{{ + ResourceType: "document", + ResourceID: resourceID, + }}) + require.NoError(t, err) + + baseLen := len(docs) + merged := service.MergeWithTeamRules(member, docs) + require.Len(t, merged, baseLen) + + var explicitUser *share.SharingRecord + for i := range merged { + if merged[i].PrincipalType == security.PrincipalTypeUser && merged[i].PrincipalID == member.MustGetUserID() { + explicitUser = &merged[i] + break + } + } + + require.NotNil(t, explicitUser) + require.Equal(t, share.Edit, explicitUser.Permission) + require.NotEqual(t, share.ViaInherit, explicitUser.Via) +} + +func TestSharing_ConcurrentDuplicateShareRequestsDoNotCreateMultipleRules(t *testing.T) { + require.NoError(t, setupErr) + + service := share.NewSharingService() + owner := createTestSession("owner-race-1", nil, "tenant-a") + resourceID := uniqueResourceID("res-race") + seedTestResource(t, owner, resourceID) + seedUserPrincipal(t, "race-target-1", "tenant-a") + + const workers = 16 + start := make(chan struct{}) + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + ctx := createORMContext(owner) + _, _ = service.CreateOrUpdateShares(ctx, owner.MustGetUserID(), &share.ShareRequest{ + Shares: []share.SharingRecord{{ + SimplifySharingRecord: share.SimplifySharingRecord{ + ResourceEntity: share.ResourceEntity{ResourceType: "document", ResourceID: resourceID}, + PrincipalType: security.PrincipalTypeUser, + PrincipalID: "race-target-1", + Permission: share.View, + }, + }}, + }) + }() + } + close(start) + wg.Wait() + + rules := getAllResourceRules(t, "document", resourceID) + require.Len(t, rules, 1, "duplicate concurrent shares must collapse to a single stored rule") +} + +// TestSharing_DelegationChain tests the full permission delegation chain: +// Owner A → shares with B (Share perm) → B reshares to C (Edit perm) → C cannot reshare → D has no access +func TestSharing_DelegationChain(t *testing.T) { + require.NoError(t, setupErr) + + service := share.NewSharingService() + + // Setup actors + ownerA := createTestSession("chain-owner-a", nil, "tenant-a") + userB := createTestSession("chain-user-b", nil, "tenant-a") + userC := createTestSession("chain-user-c", nil, "tenant-a") + userD := createTestSession("chain-user-d", nil, "tenant-a") + + resourceID := uniqueResourceID("res-chain") + seedTestResource(t, ownerA, resourceID) + seedUserPrincipal(t, "chain-user-b", "tenant-a") + seedUserPrincipal(t, "chain-user-c", "tenant-a") + seedUserPrincipal(t, "chain-user-d", "tenant-a") + + resource := share.ResourceEntity{ResourceType: "document", ResourceID: resourceID} + + // Step 1: Owner A shares with B at "Share" permission (B can reshare) + ctxA := createORMContext(ownerA) + _, err := service.CreateOrUpdateShares(ctxA, ownerA.MustGetUserID(), &share.ShareRequest{ + Shares: []share.SharingRecord{{ + SimplifySharingRecord: share.SimplifySharingRecord{ + ResourceEntity: resource, + PrincipalType: security.PrincipalTypeUser, + PrincipalID: "chain-user-b", + Permission: share.Share, + }, + }}, + }) + require.NoError(t, err, "owner A should be able to share with B") + + // Verify B has Share permission + permB, err := service.GetUserExplicitEffectivePermission(userB, resource) + require.NoError(t, err) + require.Equal(t, share.Share, permB, "B should have Share permission") + + // Step 2: B reshares to C with Edit permission (C can read/write but NOT reshare) + ctxB := createORMContext(userB) + _, err = service.CreateOrUpdateShares(ctxB, userB.MustGetUserID(), &share.ShareRequest{ + Shares: []share.SharingRecord{{ + SimplifySharingRecord: share.SimplifySharingRecord{ + ResourceEntity: resource, + PrincipalType: security.PrincipalTypeUser, + PrincipalID: "chain-user-c", + Permission: share.Edit, + }, + }}, + }) + require.NoError(t, err, "B (with Share permission) should be able to reshare to C") + + // Verify C has Edit permission + permC, err := service.GetUserExplicitEffectivePermission(userC, resource) + require.NoError(t, err) + require.Equal(t, share.Edit, permC, "C should have Edit permission") + + // Step 3: C tries to share with D → must be DENIED (Edit < Share) + ctxC := createORMContext(userC) + _, err = service.CreateOrUpdateShares(ctxC, userC.MustGetUserID(), &share.ShareRequest{ + Shares: []share.SharingRecord{{ + SimplifySharingRecord: share.SimplifySharingRecord{ + ResourceEntity: resource, + PrincipalType: security.PrincipalTypeUser, + PrincipalID: "chain-user-d", + Permission: share.View, + }, + }}, + }) + require.Error(t, err, "C (with Edit permission) must NOT be able to reshare") + + // Step 4: D has no access at all + permD, err := service.GetUserExplicitEffectivePermission(userD, resource) + require.NoError(t, err) + require.Equal(t, share.None, permD, "D (never shared with) must have no permission") + + // Step 5: Verify B can also revoke the share to C (B has Share permission) + rules := getAllResourceRules(t, "document", resourceID) + var shareToC *share.SharingRecord + for i := range rules { + if rules[i].PrincipalID == "chain-user-c" { + shareToC = &rules[i] + break + } + } + require.NotNil(t, shareToC, "share record for C must exist") + + _, err = service.CreateOrUpdateShares(ctxB, userB.MustGetUserID(), &share.ShareRequest{ + Revokes: []share.SharingRecord{ + {ORMObjectBase: orm.ORMObjectBase{ID: shareToC.ID}}, + }, + }) + require.NoError(t, err, "B (with Share permission) should be able to revoke C's share") + + // After revoke, C has no access + permCAfter, err := service.GetUserExplicitEffectivePermission(userC, resource) + require.NoError(t, err) + require.Equal(t, share.None, permCAfter, "C should have no permission after revoke") +} + +// TestSharing_RevokeImmediatelyDeniesAccess verifies that once a share is revoked, +// the target user instantly loses all access — tested with fresh user sessions to +// simulate independent logins. +func TestSharing_RevokeImmediatelyDeniesAccess(t *testing.T) { + require.NoError(t, setupErr) + + service := share.NewSharingService() + owner := createTestSession("revoke-owner-1", nil, "tenant-a") + resourceID := uniqueResourceID("res-revoke-instant") + resource := share.ResourceEntity{ResourceType: "document", ResourceID: resourceID} + + seedTestResource(t, owner, resourceID) + seedUserPrincipal(t, "revoke-target-1", "tenant-a") + seedUserPrincipal(t, "revoke-target-2", "tenant-a") + + // Owner shares with target-1 (Edit) and target-2 (Share) + ctxOwner := createORMContext(owner) + _, err := service.CreateOrUpdateShares(ctxOwner, owner.MustGetUserID(), &share.ShareRequest{ + Shares: []share.SharingRecord{ + {SimplifySharingRecord: share.SimplifySharingRecord{ + ResourceEntity: resource, + PrincipalType: security.PrincipalTypeUser, + PrincipalID: "revoke-target-1", + Permission: share.Edit, + }}, + {SimplifySharingRecord: share.SimplifySharingRecord{ + ResourceEntity: resource, + PrincipalType: security.PrincipalTypeUser, + PrincipalID: "revoke-target-2", + Permission: share.Share, + }}, + }, + }) + require.NoError(t, err) + + // Simulate target-1 logging in (fresh session) — should have Edit + target1Login1 := createTestSession("revoke-target-1", nil, "tenant-a") + perm1, err := service.GetUserExplicitEffectivePermission(target1Login1, resource) + require.NoError(t, err) + require.Equal(t, share.Edit, perm1, "target-1 should have Edit before revoke") + + // Simulate target-2 logging in — should have Share + target2Login1 := createTestSession("revoke-target-2", nil, "tenant-a") + perm2, err := service.GetUserExplicitEffectivePermission(target2Login1, resource) + require.NoError(t, err) + require.Equal(t, share.Share, perm2, "target-2 should have Share before revoke") + + // Owner revokes target-1's access + rules := getAllResourceRules(t, "document", resourceID) + var target1Share *share.SharingRecord + for i := range rules { + if rules[i].PrincipalID == "revoke-target-1" { + target1Share = &rules[i] + break + } + } + require.NotNil(t, target1Share) + + _, err = service.CreateOrUpdateShares(ctxOwner, owner.MustGetUserID(), &share.ShareRequest{ + Revokes: []share.SharingRecord{ + {ORMObjectBase: orm.ORMObjectBase{ID: target1Share.ID}}, + }, + }) + require.NoError(t, err) + + // Simulate target-1 logging in AGAIN (completely fresh session) — must have ZERO access + target1Login2 := createTestSession("revoke-target-1", nil, "tenant-a") + permAfter, err := service.GetUserExplicitEffectivePermission(target1Login2, resource) + require.NoError(t, err) + require.Equal(t, share.None, permAfter, "target-1 must have NO access after revoke (fresh login)") + + // target-2 still has access (not revoked) — fresh session + target2Login2 := createTestSession("revoke-target-2", nil, "tenant-a") + perm2After, err := service.GetUserExplicitEffectivePermission(target2Login2, resource) + require.NoError(t, err) + require.Equal(t, share.Share, perm2After, "target-2 must still have Share (only target-1 was revoked)") + + // Now revoke target-2 as well + rules = getAllResourceRules(t, "document", resourceID) + var target2Share *share.SharingRecord + for i := range rules { + if rules[i].PrincipalID == "revoke-target-2" { + target2Share = &rules[i] + break + } + } + require.NotNil(t, target2Share) + + _, err = service.CreateOrUpdateShares(ctxOwner, owner.MustGetUserID(), &share.ShareRequest{ + Revokes: []share.SharingRecord{ + {ORMObjectBase: orm.ORMObjectBase{ID: target2Share.ID}}, + }, + }) + require.NoError(t, err) + + // target-2 fresh login — now also denied + target2Login3 := createTestSession("revoke-target-2", nil, "tenant-a") + perm2Final, err := service.GetUserExplicitEffectivePermission(target2Login3, resource) + require.NoError(t, err) + require.Equal(t, share.None, perm2Final, "target-2 must have NO access after revoke (fresh login)") + + // Verify no sharing records remain for this resource + finalRules := getAllResourceRules(t, "document", resourceID) + require.Empty(t, finalRules, "all shares revoked — no records should remain") +} + +func TestSharing_BatchGetSharesReturnsErrorInsteadOfPanic(t *testing.T) { + require.NoError(t, setupErr) + + service := share.NewSharingService() + user := createTestSession("panic-guard-u1", nil, "tenant-a") + ctx := createORMContext(user) + + require.NotPanics(t, func() { + _, err := service.BatchGetShares(ctx, user, []share.ResourceEntity{{}}) + require.Error(t, err) + }) +} + +func TestSharing_GetSharingRulesReturnsErrorInsteadOfPanic(t *testing.T) { + require.NoError(t, setupErr) + + require.NotPanics(t, func() { + _, err := share.GetSharingRules(nil, "", "", "", nil) + require.Error(t, err) + }) +} + +func TestSharing_GetSharingRulesV2ReturnsErrorInsteadOfPanic(t *testing.T) { + require.NoError(t, setupErr) + + require.NotPanics(t, func() { + _, err := share.GetSharingRulesV2(nil, "", "", "", nil) + require.Error(t, err) + }) +} diff --git a/modules/security/share/testdata/docker-compose.yml b/modules/security/share/testdata/docker-compose.yml new file mode 100644 index 000000000..838b24d7f --- /dev/null +++ b/modules/security/share/testdata/docker-compose.yml @@ -0,0 +1,26 @@ +version: "3.8" + +services: + easysearch: + image: infinilabs/easysearch:2.2.0-2776 + container_name: sharing-test-easysearch + environment: + - EASYSEARCH_INITIAL_ADMIN_PASSWORD=ShareTest_2026! + ulimits: + memlock: + soft: -1 + hard: -1 + ports: + - "19200:9200" + healthcheck: + test: ["CMD", "curl", "-sku", "admin:ShareTest_2026!", "https://localhost:9200/_cluster/health"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 30s + volumes: + - ./easysearch.yml:/app/easysearch/config/easysearch.yml + - esdata:/app/easysearch/data + +volumes: + esdata: diff --git a/modules/security/share/testdata/easysearch.yml b/modules/security/share/testdata/easysearch.yml new file mode 100644 index 000000000..287879fa2 --- /dev/null +++ b/modules/security/share/testdata/easysearch.yml @@ -0,0 +1,76 @@ +cluster.name: infinilabs +node.name: node-1 +network.host: 0.0.0.0 +http.port: 9200 +transport.port: 9300 +bootstrap.memory_lock: false +bootstrap.system_call_filter: false + +cluster.initial_master_nodes: ["node-1"] +cluster.routing.allocation.disk.watermark.low: 3gb +cluster.routing.allocation.disk.watermark.high: 2gb +cluster.routing.allocation.disk.watermark.flood_stage: 1gb + +http.compression: true + +security.enabled: true +security.audit.type: noop +security.settings.expose: + - security.audit.type +security.ssl.transport.cert_file: instance.crt +security.ssl.transport.key_file: instance.key +security.ssl.transport.ca_file: ca.crt +security.ssl.transport.skip_domain_verify: true +security.ssl.http.enabled: true +security.ssl.http.cert_file: instance.crt +security.ssl.http.key_file: instance.key +security.ssl.http.ca_file: ca.crt +security.ssl.http.enabled_ciphers: + - "TLS_AES_128_GCM_SHA256" + - "TLS_AES_256_GCM_SHA384" + - "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA" + - "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256" + - "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" + - "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA" + - "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384" + - "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" +security.ssl.http.enabled_protocols: + - "TLSv1.2" + - "TLSv1.3" +security.ssl.transport.enabled_ciphers: + - "TLS_AES_128_GCM_SHA256" + - "TLS_AES_256_GCM_SHA384" + - "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA" + - "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256" + - "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" + - "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA" + - "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384" + - "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" +security.ssl.transport.enabled_protocols: + - "TLSv1.2" + - "TLSv1.3" + +security.allow_default_init_securityindex: true + +security.nodes_dn: + - 'CN=infini.cloud,OU=UNIT,O=ORG,L=NI,ST=FI,C=IN' + +security.restapi.roles_enabled: [ "superuser", "security_rest_api_access", "security" ] + +security.system_indices.enabled: true +security.ssl.http.clientauth_mode: OPTIONAL +security.system_indices.indices: [".infini-*"] + +#for admin dn +## specify admin certs to operate against system indices, basic_auth is not required +## curl -k --cert config/admin.crt --key config/admin.key -XDELETE 'https://localhost:9200/.infini-*/' +security.authcz.admin_dn: + - 'CN=admin.infini.cloud,OU=UNIT,O=ORG,L=NI,ST=FI,C=IN' + +# Password strength rules for password complexity. +# If you want to set up password strength rules for internal users, you can use the below settings for it. +# Password validation rules can be configured through regex. In the below regex example, a user must need +# a password with minimum 8 characters length and must include minimum one uppercase, one lower case, one digit, and one special character. +# And a custom error message can be configured, in case if a password is not created according to the password strength rule. +security.restapi.password_validation_regex: '(?=.*[A-Z])(?=.*[^a-zA-Z\d])(?=.*[0-9])(?=.*[a-z]).{8,}' +security.restapi.password_validation_error_message: "A password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one digit, and one special character." \ No newline at end of file diff --git a/modules/security/share/testdata/start.sh b/modules/security/share/testdata/start.sh new file mode 100755 index 000000000..708fde20d --- /dev/null +++ b/modules/security/share/testdata/start.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Start EasySearch Docker for sharing module integration tests +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "Starting EasySearch for sharing module tests..." +docker compose up -d + +echo "Waiting for EasySearch to be healthy..." +timeout=120 +elapsed=0 +while [ $elapsed -lt $timeout ]; do + if curl -sku "admin:ShareTest_2026!" https://localhost:19200/_cluster/health 2>/dev/null | grep -q '"status"'; then + echo "EasySearch is ready!" + exit 0 + fi + sleep 2 + elapsed=$((elapsed + 2)) + echo " waiting... (${elapsed}s)" +done + +echo "ERROR: EasySearch failed to start within ${timeout}s" +docker compose logs +exit 1 diff --git a/modules/security/share/testdata/stop.sh b/modules/security/share/testdata/stop.sh new file mode 100755 index 000000000..e8bc98a40 --- /dev/null +++ b/modules/security/share/testdata/stop.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Stop EasySearch Docker and clean up +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "Stopping EasySearch..." +docker compose down -v +echo "Done."