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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions core/event/audit.go
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

/* 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}"`
}
2 changes: 2 additions & 0 deletions core/orm/orm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions core/security/param_keys.go
Original file line number Diff line number Diff line change
@@ -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"
)
113 changes: 113 additions & 0 deletions modules/security/orm_hooks/audit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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")
}

//won't miss an audit message
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)
}
10 changes: 10 additions & 0 deletions modules/security/orm_hooks/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
25 changes: 19 additions & 6 deletions modules/security/share/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand All @@ -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 {
Expand Down
Loading
Loading