package authservice import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "path" "slices" "edge-infra.dev/pkg/lib/fog" "edge-infra.dev/pkg/sds/emergencyaccess/apierror" "edge-infra.dev/pkg/sds/emergencyaccess/eaconst" eamiddleware "edge-infra.dev/pkg/sds/emergencyaccess/middleware" "edge-infra.dev/pkg/sds/emergencyaccess/msgdata" "edge-infra.dev/pkg/sds/emergencyaccess/retriever" "edge-infra.dev/pkg/sds/emergencyaccess/types" ) type Dataset interface { GetProjectAndBannerID(ctx context.Context, banner string) (projectID, bannerID string, err error) GetStoreID(ctx context.Context, store, bannerID string) (storeID string, err error) GetTerminalID(ctx context.Context, terminal, storeID string) (terminalID string, err error) } type Retriever interface { Artifact(ctx context.Context, name string, artifactType retriever.ArtifactType) (retriever.Artifact, error) } const ( defaultValidateComPath = "validatecommand" getEARolesPath = "eaRoles" // Non-HTTPS scheme httpScheme = "http://" ) type AuthService struct { ds Dataset retriever Retriever rulesEngineHost string rulesEngineURL url.URL validateComPath string userServiceHost string userServiceURL url.URL getEARolesPath string edgeAPI string } func New(config Config, ds Dataset, ret Retriever, opts ...Option) (*AuthService, error) { asOpts := authserviceOpts{} for _, opt := range opts { opt(&asOpts) } as := &AuthService{ ds: ds, retriever: ret, userServiceHost: config.UserServiceHost, getEARolesPath: getEARolesPath, rulesEngineHost: config.RulesEngineHost, validateComPath: defaultValidateComPath, edgeAPI: config.EdgeAPI, } err := as.setHostURLs() if err != nil { return as, err } return as, nil } func (as *AuthService) setHostURLs() error { addr, err := url.Parse(httpScheme + as.userServiceHost) if err != nil { return err } as.userServiceURL = *addr addr, err = url.Parse(httpScheme + as.rulesEngineHost) if err != nil { return err } as.rulesEngineURL = *addr return nil } func (as AuthService) buildEARolesURL(userRoles []string) (eaRolesURL string) { addr := as.userServiceURL addr.Path = path.Join(addr.Path, as.getEARolesPath) values := url.Values{} for _, role := range userRoles { values.Add("role", role) } addr.RawQuery = values.Encode() return addr.String() } // Generic function that sends an HTTP request to the input address and formats the response. // Response is expected to be a string array func callUserService(ctx context.Context, addr string) (roles []string, err error) { log := fog.FromContext(ctx) req, err := http.NewRequestWithContext(ctx, http.MethodGet, addr, nil) if err != nil { return nil, err } correlationID := eamiddleware.GetCorrelationID(ctx) req.Header.Set(eamiddleware.CorrelationIDKey, correlationID) log.Info("Invoking userservice", "url", addr) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } log.Info("Userservice response received", "url", addr) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("user service returned status %s", resp.Status) } defer resp.Body.Close() bytes, err := io.ReadAll(resp.Body) if err != nil { return nil, err } else if len(bytes) == 0 { return nil, nil } err = json.Unmarshal(bytes, &roles) if err != nil { return nil, err } return roles, nil } type Validation struct { Valid bool `json:"valid"` } // AuthorizeRequest takes a structured request and a target and ensures that the user is allowed to execute the request on // the specified target. Target and user authorization is also done as part of the process. User details are taken from context. // // Request authorization involves validating the request structure against the message version, ensuring that a user is // allowed to execute the command, and retrieving artifacts when necessary (TBD). It is assumed that any request returned // from this method is executable on the target IEN. func (as AuthService) AuthorizeRequest(ctx context.Context, payload AuthorizeRequestPayload) (msgdata.Request, error) { log := fog.FromContext(ctx) // Create structured request interface req, err := msgdata.NewRequest(payload.Request.Data, payload.Request.Attributes) if err != nil { return nil, fmt.Errorf("failed to create structured request from payload: %w", err) } user, ok := types.UserFromContext(ctx) if !ok { return nil, errors.New("user struct not found in context") } eaRoles, err := as.getRolesForUser(ctx, user) if err != nil { return nil, err } if err := as.authorizeUser(eaRoles); err != nil { return nil, err } err = as.authorizeTarget(user, payload.Target) if err != nil { return nil, err } if artifactor, ok := req.(msgdata.Artifactor); ok { if req.RequestType() != eaconst.Executable { return nil, fmt.Errorf("unknown request type when retrieving artifact: %q", req.RequestType()) } artifact, err := as.retriever.Artifact(ctx, req.CommandToBeAuthorized(), retriever.Executable) if err != nil { return nil, fmt.Errorf("failed to retrieve artifact: %w", err) } artifactB64 := base64.StdEncoding.EncodeToString(artifact.Artifact) artifactor.WriteContents(artifactB64) } // Authorize request command in rules engine addr := as.buildRulesEngineURL(as.validateComPath) commandToBeAuthorized := req.CommandToBeAuthorized() rePayload := RulesEnginePayload{ Identity: identity{ UserID: user.Username, EAroles: eaRoles, }, Target: payload.Target, Command: RulesEngineCommand{ Name: commandToBeAuthorized, Type: req.RequestType(), }, } log.Info("Authorizing request", "commandName", commandToBeAuthorized, "commandType", req.RequestType()) val, err := as.callRulesEngine(ctx, addr, rePayload) if err != nil { return nil, err } if !val.Valid { return nil, apierror.E(apierror.ErrUnauthorizedCommand, errors.New("command not authorized for user on target")) } return req, nil } // AuthoriseCommand is used to check whether a user has the prerequisite roles to use a given command. User details are taken from the context. // EARoles are discovered from the user service. func (as AuthService) AuthorizeCommand(ctx context.Context, payload CommandAuthPayload) (Validation, error) { log := fog.FromContext(ctx) addr := as.buildRulesEngineURL(as.validateComPath) commandName, err := extractCommandName(payload.Command) if err != nil { return Validation{}, apierror.E(apierror.ErrInvalidCommand, err) } if commandName == "" { return Validation{}, apierror.E(apierror.ErrInvalidCommand, "No command identified") } if payload.AuthDetails.DarkMode { commandName = "dark" } log.Info("Extracted command to be authorized", "commandName", commandName) user, ok := types.UserFromContext(ctx) if !ok { return Validation{}, fmt.Errorf("user struct not found in context") } eaRoles, err := as.getRolesForUser(ctx, user) if err != nil { return Validation{}, err } if err := as.authorizeUser(eaRoles); err != nil { return Validation{}, err } err = as.authorizeTarget(user, payload.Target) if err != nil { return Validation{}, err } rePayload := RulesEnginePayload{ Identity: identity{ UserID: user.Username, EAroles: eaRoles, }, Target: payload.Target, Command: RulesEngineCommand{ Name: commandName, Type: eaconst.Command, }, } return as.callRulesEngine(ctx, addr, rePayload) } func (as *AuthService) authorizeUser(eaRoles []string) error { if len(eaRoles) == 0 { return apierror.E(apierror.ErrUserMissingRoles, fmt.Errorf("no roles returned from userservice"), "You have no emergency access roles assigned, please contact your administrator.", ) } return nil } // getRolesForUser returns a list of ea roles for the given user. Roles are discovered from userservice. func (as *AuthService) getRolesForUser(ctx context.Context, user types.User) ([]string, error) { eaRolesURL := as.buildEARolesURL(user.Roles) eaRoles, err := callUserService(ctx, eaRolesURL) if err != nil { return nil, fmt.Errorf("error when getting ea roles: %w", err) } return eaRoles, nil } func (as AuthService) buildRulesEngineURL(urlPathElems ...string) string { addr := as.rulesEngineURL for _, p := range urlPathElems { addr.Path = path.Join(addr.Path, p) } return addr.String() } // callRulesEngine delivers the payload to the url parsed from flags at init. Returns a Validation struct and error // for any request or json marshalling errors. func (as AuthService) callRulesEngine(ctx context.Context, addr string, rePayload RulesEnginePayload) (Validation, error) { log := fog.FromContext(ctx) data, err := json.Marshal(rePayload) if err != nil { return Validation{}, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, addr, bytes.NewBuffer(data)) if err != nil { return Validation{}, err } // add the correlation id to client correlationID := eamiddleware.GetCorrelationID(ctx) req.Header.Set(eamiddleware.CorrelationIDKey, correlationID) log.Info("Invoking rulesengine", "url", addr) resp, err := http.DefaultClient.Do(req) if err != nil { return Validation{}, err } log.Info("Rules Engine response received", "url", addr) if resp.StatusCode != http.StatusOK { err := fmt.Errorf("rules engine returned status %s", resp.Status) return Validation{}, err } defer resp.Body.Close() bytes, err := io.ReadAll(resp.Body) if err != nil { return Validation{}, err } var val Validation err = json.Unmarshal(bytes, &val) if err != nil { return Validation{}, err } return val, err } // AuthorizeTarget checks whether the target bannerid matches the one in the context. // Throws error if user struct is not set in context. func (as AuthService) AuthorizeTarget(ctx context.Context, target Target) error { user, ok := types.UserFromContext(ctx) if !ok { //checking for specific case of ok, as this should be raised as a 500 if false return fmt.Errorf("user struct not in context") } earoles, err := as.getRolesForUser(ctx, user) if err != nil { return err } if err := as.authorizeUser(earoles); err != nil { return err } return as.authorizeTarget(user, target) } func (as AuthService) authorizeTarget(user types.User, target Target) error { if !slices.Contains(user.Banners, target.BannerID) { return apierror.E(apierror.ErrUserNotAuthorized, errors.New("banner not found in user struct"), fmt.Sprintf("User was not assigned access to requested banner: %s", target.BannerID)) } return nil } func (as AuthService) ResolveTarget(ctx context.Context, payload ResolveTargetPayload) (Target, error) { projectID, bannerID, err := as.ds.GetProjectAndBannerID(ctx, payload.Target.BannerID) if err != nil { return Target{}, err } if bannerID == "" { return Target{}, apierror.E( apierror.ErrInvalidTarget, fmt.Errorf("banner %s not found", payload.Target.BannerID), fmt.Sprintf("Banner %s not found", payload.Target.BannerID), ) } if projectID == "" { return Target{}, apierror.E( apierror.ErrInvalidTarget, fmt.Errorf("project not found for banner %s", payload.Target.BannerID), fmt.Sprintf("Project not found for banner %s", payload.Target.BannerID), ) } storeID, err := as.ds.GetStoreID(ctx, payload.Target.StoreID, bannerID) if err != nil { return Target{}, err } if storeID == "" { return Target{}, apierror.E( apierror.ErrInvalidTarget, fmt.Errorf("store %s not found in given banner %s", payload.Target.StoreID, payload.Target.BannerID), fmt.Sprintf("Store %s not found in given banner %s", payload.Target.StoreID, payload.Target.BannerID), ) } terminalID, err := as.ds.GetTerminalID(ctx, payload.Target.TerminalID, storeID) if err != nil { return Target{}, err } if terminalID == "" { return Target{}, apierror.E( apierror.ErrInvalidTarget, fmt.Errorf("terminal %s not found in given store %s and banner %s", payload.Target.TerminalID, payload.Target.StoreID, payload.Target.BannerID), fmt.Sprintf("Terminal %s not found in given store %s and banner %s", payload.Target.TerminalID, payload.Target.StoreID, payload.Target.BannerID), ) } return Target{ ProjectID: projectID, BannerID: bannerID, StoreID: storeID, TerminalID: terminalID, }, nil } func (as AuthService) AuthorizeUser(ctx context.Context) error { user, ok := types.UserFromContext(ctx) if !ok { return fmt.Errorf("user struct not found in context") } eaRoles, err := as.getRolesForUser(ctx, user) if err != nil { return err } return as.authorizeUser(eaRoles) }