package services import ( "context" "database/sql" "errors" "fmt" "regexp" "slices" "strings" sqlerr "edge-infra.dev/pkg/edge/api/apierror/sql" "edge-infra.dev/pkg/edge/api/graph/model" sqlquery "edge-infra.dev/pkg/edge/api/sql" "edge-infra.dev/pkg/lib/fog" rulesengine "edge-infra.dev/pkg/sds/emergencyaccess/rules" rulesdata "edge-infra.dev/pkg/sds/emergencyaccess/rules/storage/database" ) type OperatorInterventionService interface { DeleteRoleMapping(ctx context.Context, mapping model.DeleteOperatorInterventionMappingInput) (*model.DeleteOperatorInterventionResponse, error) RoleMappings(ctx context.Context, roles []string) ([]*model.OiRoleMapping, error) UpdateRoleMappings(ctx context.Context, roleMappings []*model.UpdateOperatorInterventionRoleMappingInput) (*model.UpdateOperatorInterventionRoleMappingResponse, error) ReadPrivileges(ctx context.Context, names []*model.OperatorInterventionPrivilegeInput) ([]*model.Privilege, error) CreatePrivileges(ctx context.Context, input []*model.OperatorInterventionPrivilegeInput) (*model.CreateOperatorInterventionPrivilegeResponse, error) DeletePrivilege(ctx context.Context, payload model.OperatorInterventionPrivilegeInput) (*model.DeleteOperatorInterventionPrivilegeResponse, error) DeleteRule(ctx context.Context, payload model.DeleteOperatorInterventionRuleInput) (*model.DeleteOperatorInterventionRuleResponse, error) ReadRules(ctx context.Context, privileges []*model.OperatorInterventionPrivilegeInput) ([]*model.Rule, error) UpdateRules(ctx context.Context, rules []*model.UpdateOperatorInterventionRuleInput) (*model.UpdateOperatorInterventionRuleResponse, error) ReadCommands(ctx context.Context, payload []*model.OperatorInterventionCommandInput) ([]*model.Command, error) CreateCommands(ctx context.Context, mappings []*model.OperatorInterventionCommandInput) (*model.CreateOperatorInterventionCommandResponse, error) DeleteCommand(ctx context.Context, payload model.OperatorInterventionCommandInput) (*model.DeleteOperatorInterventionCommandResponse, error) } type rulesEngine interface { AddPrivileges(ctx context.Context, payload []rulesengine.PostPrivilegePayload) (rulesengine.AddNameResult, error) DeletePrivilege(ctx context.Context, privilege string) (rulesengine.DeleteResult, error) ReadPrivilegesWithFilter(ctx context.Context, names []string) ([]rulesengine.Privilege, error) AddCommands(ctx context.Context, names []rulesengine.PostCommandPayload) (rulesengine.AddNameResult, error) DeleteCommand(ctx context.Context, name string) (rulesengine.DeleteResult, error) ReadCommandsWithFilter(ctx context.Context, names []string) ([]rulesengine.Command, error) GetDefaultRules(ctx context.Context, privileges ...string) ([]rulesengine.ReturnRuleSet, error) AddDefaultRulesForPrivileges(ctx context.Context, data rulesengine.RuleSets) (rulesengine.AddRuleResult, error) DeleteDefaultRule(ctx context.Context, commandName, privilegeName string) (rulesengine.DeleteResult, error) } type operatorInterventionService struct { SQLDB *sql.DB reng rulesEngine } var ( /* Command name validation regex with the following rules: - alphanumeric characters, forward slashes, periods, dashes, and underscores [a-zA-Z0-9\/\.\-\_] - at least one character */ commandValidation = regexp.MustCompile(`^[a-zA-Z0-9-/._]+$`) /* Privilige name validation regex with the following rules: - The string starts with a letter ([a-zA-Z]). - The string is followed by one or more alphanumeric characters or dashes ([a-zA-Z0-9\-]+). */ privilegeValidation = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9-]+$`) ) func (o *operatorInterventionService) DeleteRoleMapping(ctx context.Context, mapping model.DeleteOperatorInterventionMappingInput) (result *model.DeleteOperatorInterventionResponse, err error) { result = &model.DeleteOperatorInterventionResponse{} if mapping.Role == "" { // Don't validate the supplied OiRole is a valid edge role. This is // because we may need to clean up incorrect data that was manually // added to the database result.Errors = append(result.Errors, &model.OperatorInterventionErrorResponse{Type: model.OperatorInterventionErrorTypeUnknownRole, Role: &mapping.Role}) } if mapping.Privilege.Name == "" { result.Errors = append(result.Errors, &model.OperatorInterventionErrorResponse{Type: model.OperatorInterventionErrorTypeUnknownPrivilege, Privilege: &mapping.Privilege.Name}) } if len(result.Errors) != 0 { return result, nil } res, err := o.SQLDB.ExecContext(ctx, sqlquery.DeleteOIRoleMapping, mapping.Role, mapping.Privilege.Name) if err != nil { return nil, fmt.Errorf("error executing sql query: %w", err) } // Find out if the statement was successful by checking if a row was deleted count, err := res.RowsAffected() if err != nil { return nil, fmt.Errorf("error finding rows affected: %w", err) } if count == 1 { // Query was successful and we can exit return result, nil } // Otherwise, return an error indicating the role mapping is unknown. result.Errors = append(result.Errors, &model.OperatorInterventionErrorResponse{ Type: model.OperatorInterventionErrorTypeUnknownRoleMapping, Role: &mapping.Role, Privilege: &mapping.Privilege.Name, }) return result, nil } func (o *operatorInterventionService) RoleMappings(ctx context.Context, roles []string) ([]*model.OiRoleMapping, error) { var rows *sql.Rows var err error if len(roles) != 0 { rows, err = o.SQLDB.QueryContext(ctx, sqlquery.GetOiRoleMappingSubset, roles) } else { rows, err = o.SQLDB.QueryContext(ctx, sqlquery.GetOIRoleMapping) } if err != nil { return nil, err } return o.scanOiRoleMappingRows(rows) } // scans a *sql.Rows of role_name, privilege_name rows into a []*model.OiRole. // Expects the sql.Rows to be ordered by role_name func (o *operatorInterventionService) scanOiRoleMappingRows(rows *sql.Rows) ([]*model.OiRoleMapping, error) { defer rows.Close() roles := []*model.OiRoleMapping{} // Index of current Role mapping in slice. Start at -1 to make first // iteration of loop nicer so that we can increment to the 0th index on the // first iteration var idx = -1 var prevRole string for rows.Next() { var roleName, privilegeName string err := rows.Scan(&roleName, &privilegeName) if err != nil { return nil, err } // The sql query should sort the results by the role name // If the rolename is the same as the previous role name we can // immediately append the privilege to the previous role mapping if roleName == prevRole { roles[idx].Privileges = append( roles[idx].Privileges, &model.Privilege{Name: privilegeName}, ) } else { // Otherwise create a new role mapping for the new role at the end // of the slice, and add a single privilege to it. idx++ prevRole = roleName roles = append( roles, &model.OiRoleMapping{ Role: model.Role(roleName), Privileges: []*model.Privilege{ {Name: privilegeName}, }, }, ) } } if err := rows.Err(); err != nil { return nil, sqlerr.Wrap(err) } return roles, nil } func (o *operatorInterventionService) UpdateRoleMappings(ctx context.Context, roleMappings []*model.UpdateOperatorInterventionRoleMappingInput) (roleMappingResponse *model.UpdateOperatorInterventionRoleMappingResponse, err error) { /* Requirements for insert: 1. Multiple inserts should take a single DB query. Additional queries may be done if there is unexpected behaviour, e.g. duplicate mappings or missing privileges. 2. All or nothing. If one privilege out of the full set is missing, no mappings should be inserted into the DB. 3. If a mapping already exists in the DB the mapping should remain unchanged and a success result returned. 4. If there is one or more missing privileges, the returned error must identify all missing privileges. Algorithm: Attempt to insert all (role, privilege) pairs in a single query Count the number of additions made in the query If equal to the number of pairs: Everything has been added successfully Commit the transaction and return with success If not equal: (this indicates there is either a duplicate pair already present in the DB or one of the privileges don't exist in the ea_rules_privileges table) Query for all privileges in the ea_rules_privileges table Find any privs in the (role, privilege) pairs that are not present in ea_rules_privileges If there are missing privileges: Return an error for every missing privilege If none are missing: This indicates the missing additions are from pre-existing duplicate entries This is ok Return success result */ // Start a transaction transaction, err := o.SQLDB.BeginTx(ctx, nil) if err != nil { return nil, err } defer func() { // All or nothing behaviour - rollback if there were any errors if err != nil || (roleMappingResponse != nil && len(roleMappingResponse.Errors) != 0) { err = errors.Join(err, transaction.Rollback()) } else { // Only commit transaction on no errors err = transaction.Commit() } }() sqlStatementData, errorResponse := processRoleMappings(roleMappings) if sqlStatementData.SQLStatement == "" { // No statement indicates all roles/privileges were invalid // We inspect the returned statement here rather than a non-empty // errorResponse as this gives us the ability to later detect if any of // the supplied privileges are not known within the DB. return &model.UpdateOperatorInterventionRoleMappingResponse{Errors: errorResponse}, nil } if len(sqlStatementData.Privileges) > 500 { // protect the database from a massive bulk insert attempt return nil, fmt.Errorf("attempting to insert too many role mappings") } res, err := transaction.ExecContext(ctx, sqlStatementData.SQLStatement, sqlStatementData.QueryArguments...) if err != nil { return nil, fmt.Errorf("failed to execute query: %w", err) } // Check how many rows were inserted. If it's not equal to the number that // we attempted to insert, then either there was duplicate mappings that // made no changes, or there was privileges that don't exist in the // ea_rules_privileges table. If the former, then we can continue onwards, // but if it's the latter then we should return an error noRows, err := res.RowsAffected() if err != nil { return nil, fmt.Errorf("error discovering number of rows: %w", err) } if int(noRows) != len(sqlStatementData.Privileges) { missingPrivs, err := findMissingPrivs(ctx, transaction, sqlStatementData.Privileges) if err != nil { return nil, fmt.Errorf("error finding missing privileges: %w", err) } // Add all missing privileges to the list of errors to be returned, // if there were no missing privileges then this will add no errors to // the list for _, priv := range missingPrivs { priv := priv errorResponse = append(errorResponse, &model.OperatorInterventionErrorResponse{ Type: model.OperatorInterventionErrorTypeUnknownPrivilege, Privilege: &priv, }) } } return &model.UpdateOperatorInterventionRoleMappingResponse{Errors: errorResponse}, nil } // processRoleMappingsResult encapsulates the results of processing role mappings. type processRoleMappingsResult struct { SQLStatement string QueryArguments []any Privileges []string } // processRoleMappings builds up a valid SQL Statement to insert all role // mappings. Additionally returns the slice of args to be passed when executing // the query, and a slice of all privilege names added for all roles. The // model.OiErrorResponse return value indicates if any of the incoming roles or // privileges are invalid. func processRoleMappings(AddRoleMappings []*model.UpdateOperatorInterventionRoleMappingInput) (processRoleMappingsResult, []*model.OperatorInterventionErrorResponse) { roles, privileges, errorResponse := decomposeRoleMappings(AddRoleMappings) // If all roles are invalid and there is nothing to insert exit early if len(roles) == 0 { return processRoleMappingsResult{}, errorResponse } params, args := generateQueryParameters(roles, privileges) // Build up full sql statement including query placeholder values stmt := generateSQLQuery(params) return processRoleMappingsResult{ SQLStatement: stmt, QueryArguments: args, Privileges: privileges, }, errorResponse } // generateSQLQuery generates a valid SQL Query which can be used to insert // role mappings in the DB. This query includes a variable number of placeholder // values to insert all mappings in a single query, and uses // [sqlquery.OiInsertRoleMappingPartialQueryBegin]. func generateSQLQuery(params []string) string { return fmt.Sprintf("%s %s %s", sqlquery.OiInsertRoleMappingPartialQueryBegin, strings.Join(params, ", "), sqlquery.OiInsertRoleMappingPartialQueryEnd, ) } // decomposeRoleMappings is used to generate 2 slices, one of roles and one of // privileges. The slices should be a 1-to-1 mapping of the role -> privilege // pairs that should be added to the DB, and must be equivalent in length. // The model.OiErrorResponse return value is used to indicate if any of the // supplied roles are not valid Edge Roles. func decomposeRoleMappings(AddRoleMappings []*model.UpdateOperatorInterventionRoleMappingInput) ([]string, []string, []*model.OperatorInterventionErrorResponse) { var errorResponse []*model.OperatorInterventionErrorResponse // Lists of roles and prvileges, the lists should be a 1-to-1 mapping for // each mapping to be added to the db, i.e. if multiple privileges are to be // added for a single role, the roles slice will contain duplicate entries var roles []string var privileges []string // Build up the full set of roles and privileges to be added to the DB for _, roleMapping := range AddRoleMappings { if !model.Role(roleMapping.Role).IsValid() { // Don't attempt to add an invalid role, add the role to the list of // errors and continue to the next role to make sure the returned // list of invalid values is as full as possible errorResponse = append(errorResponse, &model.OperatorInterventionErrorResponse{Type: model.OperatorInterventionErrorTypeUnknownRole, Role: &roleMapping.Role}) continue } for _, privilege := range roleMapping.Privileges { privilege := privilege if privilege.Name == "" { errorResponse = append(errorResponse, &model.OperatorInterventionErrorResponse{ Type: model.OperatorInterventionErrorTypeUnknownPrivilege, Privilege: &privilege.Name, }) continue } roles = append(roles, roleMapping.Role) privileges = append(privileges, privilege.Name) } } return roles, privileges, errorResponse } // generateQueryParameters accepts 2 slices, representing the 1-to-1 mapping // between roles and privileges to be added to the DB. It creates a slice of the // placeholder values to be inserted into the // [sqlquery.OiInsertRoleMappingPartialQueryBegin] query, along with a slice of // args to be passed when executing the sql query. func generateQueryParameters(roles []string, privileges []string) ([]string, []any) { // List of roles and privileges to be passed to query, alternating between role // and privilege, e.g. `[]string{"EDGE_ORG_ADMIN", "ea-basic", "EDGE_BANNER_ADMIN", "ea-read"}` var args []any // Slice of query placeholder values, e.g. `[]string{"($1, $2)", "($3, $4)"}` var params []string for idx := range roles { args = append(args, roles[idx], privileges[idx]) roleIdx := fmt.Sprintf("$%d", idx*2+1) privIdx := fmt.Sprintf("$%d", idx*2+2) params = append(params, fmt.Sprintf(`(%s, %s)`, roleIdx, privIdx)) } return params, args } // findMissingPrivs returns a subset of the passed in privileges. This subset // corresponds to the elements of privileges which are not present within the // ea_rules_privileges table. If all privileges are present in the db table an // empty slice is returned. func findMissingPrivs(ctx context.Context, tx *sql.Tx, privileges []string) ([]string, error) { rows, err := tx.QueryContext(ctx, sqlquery.GetOiPrivilegesSubset, privileges) if err != nil { return nil, fmt.Errorf("error selecting privileges: %w", err) } defer rows.Close() foundPrivs := []string{} for rows.Next() { var privName string err := rows.Scan(&privName) if err != nil { return nil, fmt.Errorf("error scanning privs row: %w", err) } foundPrivs = append(foundPrivs, privName) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("error while reading privileges: %w", err) } return difference(privileges, foundPrivs), nil } // difference returns all members of the superset that are not present in the // subset func difference(superset []string, subset []string) []string { diff := []string{} for _, u := range superset { if !slices.Contains(subset, u) { diff = append(diff, u) } } return diff } func (o *operatorInterventionService) ReadCommands(ctx context.Context, payload []*model.OperatorInterventionCommandInput) ([]*model.Command, error) { var names []string for _, command := range payload { names = append(names, command.Name) } ret, err := o.reng.ReadCommandsWithFilter(ctx, names) if err != nil { return nil, err } var commands []*model.Command for _, command := range ret { commands = append(commands, &model.Command{Name: command.Name}) } return commands, nil } func (o *operatorInterventionService) CreateCommands(ctx context.Context, mappings []*model.OperatorInterventionCommandInput) (*model.CreateOperatorInterventionCommandResponse, error) { var payload []rulesengine.PostCommandPayload for _, mapping := range mappings { payload = append(payload, rulesengine.PostCommandPayload{Name: mapping.Name}) } // Validate that payload list is non-nil if len(payload) == 0 { e := &model.OperatorInterventionErrorResponse{Type: model.OperatorInterventionErrorTypeInvalidInput} return &model.CreateOperatorInterventionCommandResponse{ Errors: []*model.OperatorInterventionErrorResponse{e}, }, nil } // validate the command names using the regex var errs []*model.OperatorInterventionErrorResponse for _, p := range payload { p := p if !commandValidation.MatchString(p.Name) { errs = append(errs, &model.OperatorInterventionErrorResponse{ Type: model.OperatorInterventionErrorTypeInvalidInput, Command: &p.Name}) } } if len(errs) > 0 { return &model.CreateOperatorInterventionCommandResponse{ Errors: errs, }, nil } // We have no need to see the result as we are not expecting any // conflicts to be returned from rulesengine _, err := o.reng.AddCommands(ctx, payload) return &model.CreateOperatorInterventionCommandResponse{}, err } //nolint:dupl func (o *operatorInterventionService) DeleteCommand(ctx context.Context, payload model.OperatorInterventionCommandInput) (*model.DeleteOperatorInterventionCommandResponse, error) { if payload.Name == "" { e := &model.OperatorInterventionErrorResponse{Type: model.OperatorInterventionErrorTypeInvalidInput} return &model.DeleteOperatorInterventionCommandResponse{ Errors: []*model.OperatorInterventionErrorResponse{e}, }, nil } ret, err := o.reng.DeleteCommand(ctx, payload.Name) if err != nil { return nil, err } var errs []*model.OperatorInterventionErrorResponse for _, err := range ret.Errors { commandCopy := payload.Name errType := model.OperatorInterventionErrorTypeUnknownCommand if err.Type == rulesengine.Conflict { errType = model.OperatorInterventionErrorTypeConflict } errs = append(errs, &model.OperatorInterventionErrorResponse{ Type: errType, Command: &commandCopy, }) } return &model.DeleteOperatorInterventionCommandResponse{ Errors: errs, }, nil } // CreatePrivileges creates operator intervention privileges. // It takes a context and a slice of CreateOperatorInterventionPrivilegeInput as input. // It returns a CreateOperatorInterventionPrivilegeResponse and an error. func (o *operatorInterventionService) CreatePrivileges(ctx context.Context, input []*model.OperatorInterventionPrivilegeInput) (*model.CreateOperatorInterventionPrivilegeResponse, error) { var payload []rulesengine.PostPrivilegePayload for _, mapping := range input { payload = append(payload, rulesengine.PostPrivilegePayload{Name: mapping.Name}) } // Validate that payload list is non-nil if len(payload) == 0 { e := &model.OperatorInterventionErrorResponse{Type: model.OperatorInterventionErrorTypeInvalidInput} return &model.CreateOperatorInterventionPrivilegeResponse{ Errors: []*model.OperatorInterventionErrorResponse{e}, }, nil } // validate the privilege names using the regex var errs []*model.OperatorInterventionErrorResponse for _, p := range payload { p := p if !privilegeValidation.MatchString(p.Name) { errs = append(errs, &model.OperatorInterventionErrorResponse{ Type: model.OperatorInterventionErrorTypeInvalidInput, Privilege: &p.Name}) } } if len(errs) > 0 { return &model.CreateOperatorInterventionPrivilegeResponse{ Errors: errs, }, nil } _, err := o.reng.AddPrivileges(ctx, payload) if err != nil { return nil, err } return &model.CreateOperatorInterventionPrivilegeResponse{ Errors: nil, }, nil } // ReadPrivileges retrieves operator intervention privileges based on the provided privilege names. // It returns a list of privileges that match the provided names, or all privileges if no names are provided. // If an error occurs during the retrieval process, it returns nil and the error. func (o *operatorInterventionService) ReadPrivileges(ctx context.Context, payload []*model.OperatorInterventionPrivilegeInput) ([]*model.Privilege, error) { var names []string for _, name := range payload { names = append(names, name.Name) } ret, err := o.reng.ReadPrivilegesWithFilter(ctx, names) if err != nil { return nil, err } var privileges []*model.Privilege for _, privilege := range ret { privileges = append(privileges, &model.Privilege{Name: privilege.Name}) } return privileges, nil } // DeletePrivilege deletes the operator intervention privilege based on the provided payload. // It returns a response containing any errors encountered during the deletion process. // //nolint:dupl func (o *operatorInterventionService) DeletePrivilege(ctx context.Context, payload model.OperatorInterventionPrivilegeInput) (*model.DeleteOperatorInterventionPrivilegeResponse, error) { if payload.Name == "" { e := &model.OperatorInterventionErrorResponse{Type: model.OperatorInterventionErrorTypeInvalidInput} return &model.DeleteOperatorInterventionPrivilegeResponse{ Errors: []*model.OperatorInterventionErrorResponse{e}, }, nil } ret, err := o.reng.DeletePrivilege(ctx, payload.Name) if err != nil { return nil, err } var errs []*model.OperatorInterventionErrorResponse for _, err := range ret.Errors { privilegeCopy := payload.Name errType := model.OperatorInterventionErrorTypeUnknownPrivilege if err.Type == rulesengine.Conflict { errType = model.OperatorInterventionErrorTypeConflict } errs = append(errs, &model.OperatorInterventionErrorResponse{ Type: errType, Privilege: &privilegeCopy, }) } return &model.DeleteOperatorInterventionPrivilegeResponse{ Errors: errs, }, nil } func NewOperatorInterventionService(sqlDB *sql.DB) *operatorInterventionService { //nolint // Rulesengine Dataset struct does not log anything so it is ok to use // a brand new logger for the sake of initialization ds := rulesdata.New(fog.New(), sqlDB) reng := rulesengine.New(ds) return &operatorInterventionService{ SQLDB: sqlDB, reng: reng, } }