package acl import ( "context" "net/http" "strings" "connectrpc.com/connect" "github.com/OliveTin/OliveTin/internal/auth" config "github.com/OliveTin/OliveTin/internal/config" log "github.com/sirupsen/logrus" "golang.org/x/exp/slices" ) type PermissionBits int const ( View PermissionBits = 1 << iota Exec Logs Kill ) func (p PermissionBits) Has(permission PermissionBits) bool { return p&permission != 0 } // User respresents a person. type AuthenticatedUser struct { Username string UsergroupLine string Provider string SID string Acls []string EffectivePolicy *config.ConfigurationPolicy } func (u *AuthenticatedUser) IsGuest() bool { return u.Username == "guest" && u.Provider == "system" } func (u *AuthenticatedUser) parseUsergroupLine(sep string) []string { ret := []string{} if sep != "" { for _, v := range strings.Split(u.UsergroupLine, sep) { trimmed := strings.TrimSpace(v) if trimmed != "" { ret = append(ret, trimmed) } } } else { ret = strings.Fields(u.UsergroupLine) } log.Debugf("parseUsergroupLine: %v, %v, sep:%v", u.UsergroupLine, ret, sep) return ret } func (u *AuthenticatedUser) matchesUsergroupAcl(matchUsergroups []string, sep string) bool { groupList := u.parseUsergroupLine(sep) for _, group := range groupList { if slices.Contains(matchUsergroups, group) { log.Debugf("Usergroup %v found in %+v (len: %v)", group, groupList, len(groupList)) return true } } return false } func logAclNotMatched(cfg *config.Config, aclFunction string, user *AuthenticatedUser, action *config.Action, acl *config.AccessControlList) { if cfg.LogDebugOptions.AclNotMatched { log.WithFields(log.Fields{ "User": user.Username, "Action": action.Title, "ACL": acl.Name, }).Debugf("%v - ACL Not Matched", aclFunction) } } func logAclMatched(cfg *config.Config, aclFunction string, user *AuthenticatedUser, action *config.Action, acl *config.AccessControlList) { actionTitle := "N/A" if action != nil { actionTitle = action.Title } if cfg.LogDebugOptions.AclMatched { log.WithFields(log.Fields{ "User": user.Username, "Action": actionTitle, "ACL": acl.Name, }).Debugf("%v - Matched ACL", aclFunction) } } func logAclNoneMatched(cfg *config.Config, aclFunction string, user *AuthenticatedUser, action *config.Action, defaultPermission bool) { if cfg.LogDebugOptions.AclNoneMatched { log.WithFields(log.Fields{ "User": user.Username, "Action": action.Title, "Default": defaultPermission, }).Debugf("%v - No ACLs Matched, returning default permission", aclFunction) } } func permissionsConfigToBits(permissions config.PermissionsList) PermissionBits { type permPair struct { enabled bool bit PermissionBits } permMap := []permPair{ {permissions.View, View}, {permissions.Exec, Exec}, {permissions.Logs, Logs}, {permissions.Kill, Kill}, } var ret PermissionBits for _, perm := range permMap { if perm.enabled { ret |= perm.bit } } return ret } func aclCheck(requiredPermission PermissionBits, defaultValue bool, cfg *config.Config, aclFunction string, user *AuthenticatedUser, action *config.Action) bool { relevantAcls := getRelevantAcls(cfg, action.Acls, user) if cfg.LogDebugOptions.AclCheckStarted { log.WithFields(log.Fields{ "actionTitle": action.Title, "username": user.Username, "usergroupLine": user.UsergroupLine, "relevantAcls": len(relevantAcls), "requiredPermission": requiredPermission, }).Debugf("ACL check - %v", aclFunction) } for _, acl := range relevantAcls { permissionBits := permissionsConfigToBits(acl.Permissions) if permissionBits.Has(requiredPermission) { logAclMatched(cfg, aclFunction, user, action, acl) return true } else { logAclNotMatched(cfg, aclFunction, user, action, acl) } } logAclNoneMatched(cfg, aclFunction, user, action, cfg.DefaultPermissions.Logs) return defaultValue } // IsAllowedLogs checks if a AuthenticatedUser is allowed to view an action's logs func IsAllowedLogs(cfg *config.Config, user *AuthenticatedUser, action *config.Action) bool { return aclCheck(Logs, cfg.DefaultPermissions.Logs, cfg, "isAllowedLogs", user, action) } // IsAllowedExec checks if a AuthenticatedUser is allowed to execute an Action func IsAllowedExec(cfg *config.Config, user *AuthenticatedUser, action *config.Action) bool { return aclCheck(Exec, cfg.DefaultPermissions.Exec, cfg, "isAllowedExec", user, action) } // IsAllowedView checks if a User is allowed to view an Action func IsAllowedView(cfg *config.Config, user *AuthenticatedUser, action *config.Action) bool { if action.Hidden { return false } return aclCheck(View, cfg.DefaultPermissions.View, cfg, "isAllowedView", user, action) } func IsAllowedKill(cfg *config.Config, user *AuthenticatedUser, action *config.Action) bool { return aclCheck(Kill, cfg.DefaultPermissions.Kill, cfg, "isAllowedKill", user, action) } func getHeaderKeyOrEmpty(headers http.Header, key string) string { values := headers.Values(key) if len(values) > 0 { return values[0] } return "" } // UserFromContext tries to find a user from a Connect RPC context func UserFromContext[T any](ctx context.Context, req *connect.Request[T], cfg *config.Config) *AuthenticatedUser { user := userFromHeaders(req, cfg) if user.Username == "" { user = userFromLocalSession(req, cfg, user) } if user.Username == "" { user = *UserGuest(cfg) } else { buildUserAcls(cfg, &user) } path := "" if req != nil { path = req.Spec().Procedure } log.WithFields(log.Fields{ "username": user.Username, "usergroupLine": user.UsergroupLine, "provider": user.Provider, "acls": user.Acls, "path": path, }).Debugf("Authenticated API request") return &user } //gocyclo:ignore func userFromHeaders[T any](req *connect.Request[T], cfg *config.Config) AuthenticatedUser { var u AuthenticatedUser if req == nil { return u } if cfg.AuthHttpHeaderUsername != "" { u.Username = getHeaderKeyOrEmpty(req.Header(), cfg.AuthHttpHeaderUsername) } if cfg.AuthHttpHeaderUserGroup != "" { u.UsergroupLine = getHeaderKeyOrEmpty(req.Header(), cfg.AuthHttpHeaderUserGroup) } if prov := getHeaderKeyOrEmpty(req.Header(), "provider"); prov != "" { u.Provider = prov } return u } //gocyclo:ignore func userFromLocalSession[T any](req *connect.Request[T], cfg *config.Config, u AuthenticatedUser) AuthenticatedUser { if req == nil || u.Username != "" { return u } dummy := &http.Request{Header: req.Header()} c, err := dummy.Cookie("olivetin-sid-local") if err != nil || c == nil || c.Value == "" { return u } sess := auth.GetUserSession("local", c.Value) if sess == nil { log.WithFields(log.Fields{"sid": c.Value, "provider": "local"}).Warn("UserFromContext: stale local session") return u } if cfgUser := cfg.FindUserByUsername(sess.Username); cfgUser != nil { u.Username = cfgUser.Username u.UsergroupLine = cfgUser.Usergroup u.Provider = "local" u.SID = c.Value return u } log.WithFields(log.Fields{"username": sess.Username}).Warn("UserFromContext: local session user not in config") return u } func UserGuest(cfg *config.Config) *AuthenticatedUser { ret := &AuthenticatedUser{} ret.Username = "guest" ret.UsergroupLine = "guest" ret.Provider = "system" buildUserAcls(cfg, ret) return ret } func UserFromSystem(cfg *config.Config, username string) *AuthenticatedUser { ret := &AuthenticatedUser{ Username: username, UsergroupLine: "system", Provider: "system", } buildUserAcls(cfg, ret) return ret } func buildUserAcls(cfg *config.Config, user *AuthenticatedUser) { for _, acl := range cfg.AccessControlLists { if slices.Contains(acl.MatchUsernames, user.Username) { user.Acls = append(user.Acls, acl.Name) continue } if user.matchesUsergroupAcl(acl.MatchUsergroups, cfg.AuthHttpHeaderUserGroupSep) { user.Acls = append(user.Acls, acl.Name) continue } } user.EffectivePolicy = getEffectivePolicy(cfg, user) } func isACLRelevantToAction(cfg *config.Config, actionAcls []string, acl *config.AccessControlList, user *AuthenticatedUser) bool { if !slices.Contains(user.Acls, acl.Name) { // If the user does not have this ACL, then it is not relevant return false } if acl.AddToEveryAction { return true } if slices.Contains(actionAcls, acl.Name) { return true } return false } func getRelevantAcls(cfg *config.Config, actionAcls []string, user *AuthenticatedUser) []*config.AccessControlList { var ret []*config.AccessControlList for _, acl := range cfg.AccessControlLists { if isACLRelevantToAction(cfg, actionAcls, acl, user) { ret = append(ret, acl) } } return ret } func getEffectivePolicy(cfg *config.Config, user *AuthenticatedUser) *config.ConfigurationPolicy { ret := &config.ConfigurationPolicy{ ShowDiagnostics: cfg.DefaultPolicy.ShowDiagnostics, ShowLogList: cfg.DefaultPolicy.ShowLogList, } for _, acl := range cfg.AccessControlLists { if slices.Contains(user.Acls, acl.Name) { logAclMatched(cfg, "GetEffectivePolicy", user, nil, acl) ret = buildConfigurationPolicy(ret, acl.Policy) } } return ret } func buildConfigurationPolicy(ret *config.ConfigurationPolicy, policy config.ConfigurationPolicy) *config.ConfigurationPolicy { if policy.ShowDiagnostics { ret.ShowDiagnostics = policy.ShowDiagnostics } if policy.ShowLogList { ret.ShowLogList = policy.ShowLogList } return ret }