...

Source file src/edge-infra.dev/pkg/sds/emergencyaccess/authservice/authservice.go

Documentation: edge-infra.dev/pkg/sds/emergencyaccess/authservice

     1  package authservice
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/base64"
     7  	"encoding/json"
     8  	"errors"
     9  	"fmt"
    10  	"io"
    11  	"net/http"
    12  	"net/url"
    13  	"path"
    14  	"slices"
    15  
    16  	"edge-infra.dev/pkg/lib/fog"
    17  	"edge-infra.dev/pkg/sds/emergencyaccess/apierror"
    18  	"edge-infra.dev/pkg/sds/emergencyaccess/eaconst"
    19  	eamiddleware "edge-infra.dev/pkg/sds/emergencyaccess/middleware"
    20  	"edge-infra.dev/pkg/sds/emergencyaccess/msgdata"
    21  	"edge-infra.dev/pkg/sds/emergencyaccess/retriever"
    22  	"edge-infra.dev/pkg/sds/emergencyaccess/types"
    23  )
    24  
    25  type Dataset interface {
    26  	GetProjectAndBannerID(ctx context.Context, banner string) (projectID, bannerID string, err error)
    27  	GetStoreID(ctx context.Context, store, bannerID string) (storeID string, err error)
    28  	GetTerminalID(ctx context.Context, terminal, storeID string) (terminalID string, err error)
    29  }
    30  
    31  type Retriever interface {
    32  	Artifact(ctx context.Context, name string, artifactType retriever.ArtifactType) (retriever.Artifact, error)
    33  }
    34  
    35  const (
    36  	defaultValidateComPath = "validatecommand"
    37  	getEARolesPath         = "eaRoles"
    38  
    39  	// Non-HTTPS scheme
    40  	httpScheme = "http://"
    41  )
    42  
    43  type AuthService struct {
    44  	ds        Dataset
    45  	retriever Retriever
    46  
    47  	rulesEngineHost string
    48  	rulesEngineURL  url.URL
    49  	validateComPath string
    50  
    51  	userServiceHost string
    52  	userServiceURL  url.URL
    53  	getEARolesPath  string
    54  
    55  	edgeAPI string
    56  }
    57  
    58  func New(config Config, ds Dataset, ret Retriever, opts ...Option) (*AuthService, error) {
    59  	asOpts := authserviceOpts{}
    60  	for _, opt := range opts {
    61  		opt(&asOpts)
    62  	}
    63  
    64  	as := &AuthService{
    65  		ds:        ds,
    66  		retriever: ret,
    67  
    68  		userServiceHost: config.UserServiceHost,
    69  		getEARolesPath:  getEARolesPath,
    70  
    71  		rulesEngineHost: config.RulesEngineHost,
    72  		validateComPath: defaultValidateComPath,
    73  
    74  		edgeAPI: config.EdgeAPI,
    75  	}
    76  
    77  	err := as.setHostURLs()
    78  	if err != nil {
    79  		return as, err
    80  	}
    81  
    82  	return as, nil
    83  }
    84  
    85  func (as *AuthService) setHostURLs() error {
    86  	addr, err := url.Parse(httpScheme + as.userServiceHost)
    87  	if err != nil {
    88  		return err
    89  	}
    90  	as.userServiceURL = *addr
    91  	addr, err = url.Parse(httpScheme + as.rulesEngineHost)
    92  	if err != nil {
    93  		return err
    94  	}
    95  	as.rulesEngineURL = *addr
    96  	return nil
    97  }
    98  
    99  func (as AuthService) buildEARolesURL(userRoles []string) (eaRolesURL string) {
   100  	addr := as.userServiceURL
   101  	addr.Path = path.Join(addr.Path, as.getEARolesPath)
   102  	values := url.Values{}
   103  	for _, role := range userRoles {
   104  		values.Add("role", role)
   105  	}
   106  	addr.RawQuery = values.Encode()
   107  	return addr.String()
   108  }
   109  
   110  // Generic function that sends an HTTP request to the input address and formats the response.
   111  // Response is expected to be a string array
   112  func callUserService(ctx context.Context, addr string) (roles []string, err error) {
   113  	log := fog.FromContext(ctx)
   114  	req, err := http.NewRequestWithContext(ctx, http.MethodGet, addr, nil)
   115  	if err != nil {
   116  		return nil, err
   117  	}
   118  	correlationID := eamiddleware.GetCorrelationID(ctx)
   119  	req.Header.Set(eamiddleware.CorrelationIDKey, correlationID)
   120  	log.Info("Invoking userservice", "url", addr)
   121  	resp, err := http.DefaultClient.Do(req)
   122  	if err != nil {
   123  		return nil, err
   124  	}
   125  	log.Info("Userservice response received", "url", addr)
   126  	if resp.StatusCode != http.StatusOK {
   127  		return nil, fmt.Errorf("user service returned status %s", resp.Status)
   128  	}
   129  	defer resp.Body.Close()
   130  	bytes, err := io.ReadAll(resp.Body)
   131  	if err != nil {
   132  		return nil, err
   133  	} else if len(bytes) == 0 {
   134  		return nil, nil
   135  	}
   136  	err = json.Unmarshal(bytes, &roles)
   137  	if err != nil {
   138  		return nil, err
   139  	}
   140  	return roles, nil
   141  }
   142  
   143  type Validation struct {
   144  	Valid bool `json:"valid"`
   145  }
   146  
   147  // AuthorizeRequest takes a structured request and a target and ensures that the user is allowed to execute the request on
   148  // the specified target. Target and user authorization is also done as part of the process. User details are taken from context.
   149  //
   150  // Request authorization involves validating the request structure against the message version, ensuring that a user is
   151  // allowed to execute the command, and retrieving artifacts when necessary (TBD). It is assumed that any request returned
   152  // from this method is executable on the target IEN.
   153  func (as AuthService) AuthorizeRequest(ctx context.Context, payload AuthorizeRequestPayload) (msgdata.Request, error) {
   154  	log := fog.FromContext(ctx)
   155  
   156  	// Create structured request interface
   157  	req, err := msgdata.NewRequest(payload.Request.Data, payload.Request.Attributes)
   158  	if err != nil {
   159  		return nil, fmt.Errorf("failed to create structured request from payload: %w", err)
   160  	}
   161  
   162  	user, ok := types.UserFromContext(ctx)
   163  	if !ok {
   164  		return nil, errors.New("user struct not found in context")
   165  	}
   166  	eaRoles, err := as.getRolesForUser(ctx, user)
   167  	if err != nil {
   168  		return nil, err
   169  	}
   170  	if err := as.authorizeUser(eaRoles); err != nil {
   171  		return nil, err
   172  	}
   173  
   174  	err = as.authorizeTarget(user, payload.Target)
   175  	if err != nil {
   176  		return nil, err
   177  	}
   178  
   179  	if artifactor, ok := req.(msgdata.Artifactor); ok {
   180  		if req.RequestType() != eaconst.Executable {
   181  			return nil, fmt.Errorf("unknown request type when retrieving artifact: %q", req.RequestType())
   182  		}
   183  
   184  		artifact, err := as.retriever.Artifact(ctx, req.CommandToBeAuthorized(), retriever.Executable)
   185  		if err != nil {
   186  			return nil, fmt.Errorf("failed to retrieve artifact: %w", err)
   187  		}
   188  
   189  		artifactB64 := base64.StdEncoding.EncodeToString(artifact.Artifact)
   190  		artifactor.WriteContents(artifactB64)
   191  	}
   192  
   193  	// Authorize request command in rules engine
   194  	addr := as.buildRulesEngineURL(as.validateComPath)
   195  	commandToBeAuthorized := req.CommandToBeAuthorized()
   196  	rePayload := RulesEnginePayload{
   197  		Identity: identity{
   198  			UserID:  user.Username,
   199  			EAroles: eaRoles,
   200  		},
   201  		Target: payload.Target,
   202  		Command: RulesEngineCommand{
   203  			Name: commandToBeAuthorized,
   204  			Type: req.RequestType(),
   205  		},
   206  	}
   207  
   208  	log.Info("Authorizing request", "commandName", commandToBeAuthorized, "commandType", req.RequestType())
   209  	val, err := as.callRulesEngine(ctx, addr, rePayload)
   210  	if err != nil {
   211  		return nil, err
   212  	}
   213  	if !val.Valid {
   214  		return nil, apierror.E(apierror.ErrUnauthorizedCommand, errors.New("command not authorized for user on target"))
   215  	}
   216  
   217  	return req, nil
   218  }
   219  
   220  // AuthoriseCommand is used to check whether a user has the prerequisite roles to use a given command. User details are taken from the context.
   221  // EARoles are discovered from the user service.
   222  func (as AuthService) AuthorizeCommand(ctx context.Context, payload CommandAuthPayload) (Validation, error) {
   223  	log := fog.FromContext(ctx)
   224  
   225  	addr := as.buildRulesEngineURL(as.validateComPath)
   226  	commandName, err := extractCommandName(payload.Command)
   227  	if err != nil {
   228  		return Validation{}, apierror.E(apierror.ErrInvalidCommand, err)
   229  	}
   230  	if commandName == "" {
   231  		return Validation{}, apierror.E(apierror.ErrInvalidCommand, "No command identified")
   232  	}
   233  	if payload.AuthDetails.DarkMode {
   234  		commandName = "dark"
   235  	}
   236  
   237  	log.Info("Extracted command to be authorized", "commandName", commandName)
   238  
   239  	user, ok := types.UserFromContext(ctx)
   240  	if !ok {
   241  		return Validation{}, fmt.Errorf("user struct not found in context")
   242  	}
   243  	eaRoles, err := as.getRolesForUser(ctx, user)
   244  	if err != nil {
   245  		return Validation{}, err
   246  	}
   247  
   248  	if err := as.authorizeUser(eaRoles); err != nil {
   249  		return Validation{}, err
   250  	}
   251  
   252  	err = as.authorizeTarget(user, payload.Target)
   253  	if err != nil {
   254  		return Validation{}, err
   255  	}
   256  
   257  	rePayload := RulesEnginePayload{
   258  		Identity: identity{
   259  			UserID:  user.Username,
   260  			EAroles: eaRoles,
   261  		},
   262  		Target: payload.Target,
   263  		Command: RulesEngineCommand{
   264  			Name: commandName,
   265  			Type: eaconst.Command,
   266  		},
   267  	}
   268  	return as.callRulesEngine(ctx, addr, rePayload)
   269  }
   270  
   271  func (as *AuthService) authorizeUser(eaRoles []string) error {
   272  	if len(eaRoles) == 0 {
   273  		return apierror.E(apierror.ErrUserMissingRoles, fmt.Errorf("no roles returned from userservice"),
   274  			"You have no emergency access roles assigned, please contact your administrator.",
   275  		)
   276  	}
   277  
   278  	return nil
   279  }
   280  
   281  // getRolesForUser returns a list of ea roles for the given user. Roles are discovered from userservice.
   282  func (as *AuthService) getRolesForUser(ctx context.Context, user types.User) ([]string, error) {
   283  	eaRolesURL := as.buildEARolesURL(user.Roles)
   284  	eaRoles, err := callUserService(ctx, eaRolesURL)
   285  	if err != nil {
   286  		return nil, fmt.Errorf("error when getting ea roles: %w", err)
   287  	}
   288  	return eaRoles, nil
   289  }
   290  
   291  func (as AuthService) buildRulesEngineURL(urlPathElems ...string) string {
   292  	addr := as.rulesEngineURL
   293  	for _, p := range urlPathElems {
   294  		addr.Path = path.Join(addr.Path, p)
   295  	}
   296  	return addr.String()
   297  }
   298  
   299  // callRulesEngine delivers the payload to the url parsed from flags at init. Returns a Validation struct and error
   300  // for any request or json marshalling errors.
   301  func (as AuthService) callRulesEngine(ctx context.Context, addr string, rePayload RulesEnginePayload) (Validation, error) {
   302  	log := fog.FromContext(ctx)
   303  	data, err := json.Marshal(rePayload)
   304  	if err != nil {
   305  		return Validation{}, err
   306  	}
   307  	req, err := http.NewRequestWithContext(ctx, http.MethodPost, addr, bytes.NewBuffer(data))
   308  	if err != nil {
   309  		return Validation{}, err
   310  	}
   311  	// add the correlation id to client
   312  	correlationID := eamiddleware.GetCorrelationID(ctx)
   313  	req.Header.Set(eamiddleware.CorrelationIDKey, correlationID)
   314  	log.Info("Invoking rulesengine", "url", addr)
   315  
   316  	resp, err := http.DefaultClient.Do(req)
   317  	if err != nil {
   318  		return Validation{}, err
   319  	}
   320  	log.Info("Rules Engine response received", "url", addr)
   321  	if resp.StatusCode != http.StatusOK {
   322  		err := fmt.Errorf("rules engine returned status %s", resp.Status)
   323  		return Validation{}, err
   324  	}
   325  
   326  	defer resp.Body.Close()
   327  	bytes, err := io.ReadAll(resp.Body)
   328  	if err != nil {
   329  		return Validation{}, err
   330  	}
   331  	var val Validation
   332  	err = json.Unmarshal(bytes, &val)
   333  	if err != nil {
   334  		return Validation{}, err
   335  	}
   336  	return val, err
   337  }
   338  
   339  // AuthorizeTarget checks whether the target bannerid matches the one in the context.
   340  // Throws error if user struct is not set in context.
   341  func (as AuthService) AuthorizeTarget(ctx context.Context, target Target) error {
   342  	user, ok := types.UserFromContext(ctx)
   343  	if !ok {
   344  		//checking for specific case of ok, as this should be raised as a 500 if false
   345  		return fmt.Errorf("user struct not in context")
   346  	}
   347  	earoles, err := as.getRolesForUser(ctx, user)
   348  	if err != nil {
   349  		return err
   350  	}
   351  
   352  	if err := as.authorizeUser(earoles); err != nil {
   353  		return err
   354  	}
   355  
   356  	return as.authorizeTarget(user, target)
   357  }
   358  
   359  func (as AuthService) authorizeTarget(user types.User, target Target) error {
   360  	if !slices.Contains(user.Banners, target.BannerID) {
   361  		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))
   362  	}
   363  	return nil
   364  }
   365  
   366  func (as AuthService) ResolveTarget(ctx context.Context, payload ResolveTargetPayload) (Target, error) {
   367  	projectID, bannerID, err := as.ds.GetProjectAndBannerID(ctx, payload.Target.BannerID)
   368  	if err != nil {
   369  		return Target{}, err
   370  	}
   371  	if bannerID == "" {
   372  		return Target{}, apierror.E(
   373  			apierror.ErrInvalidTarget,
   374  			fmt.Errorf("banner %s not found", payload.Target.BannerID),
   375  			fmt.Sprintf("Banner %s not found", payload.Target.BannerID),
   376  		)
   377  	}
   378  	if projectID == "" {
   379  		return Target{}, apierror.E(
   380  			apierror.ErrInvalidTarget,
   381  			fmt.Errorf("project not found for banner %s", payload.Target.BannerID),
   382  			fmt.Sprintf("Project not found for banner %s", payload.Target.BannerID),
   383  		)
   384  	}
   385  
   386  	storeID, err := as.ds.GetStoreID(ctx, payload.Target.StoreID, bannerID)
   387  	if err != nil {
   388  		return Target{}, err
   389  	}
   390  	if storeID == "" {
   391  		return Target{}, apierror.E(
   392  			apierror.ErrInvalidTarget,
   393  			fmt.Errorf("store %s not found in given banner %s", payload.Target.StoreID, payload.Target.BannerID),
   394  			fmt.Sprintf("Store %s not found in given banner %s", payload.Target.StoreID, payload.Target.BannerID),
   395  		)
   396  	}
   397  
   398  	terminalID, err := as.ds.GetTerminalID(ctx, payload.Target.TerminalID, storeID)
   399  	if err != nil {
   400  		return Target{}, err
   401  	}
   402  	if terminalID == "" {
   403  		return Target{}, apierror.E(
   404  			apierror.ErrInvalidTarget,
   405  			fmt.Errorf("terminal %s not found in given store %s and banner %s", payload.Target.TerminalID, payload.Target.StoreID, payload.Target.BannerID),
   406  			fmt.Sprintf("Terminal %s not found in given store %s and banner %s", payload.Target.TerminalID, payload.Target.StoreID, payload.Target.BannerID),
   407  		)
   408  	}
   409  
   410  	return Target{
   411  		ProjectID:  projectID,
   412  		BannerID:   bannerID,
   413  		StoreID:    storeID,
   414  		TerminalID: terminalID,
   415  	}, nil
   416  }
   417  
   418  func (as AuthService) AuthorizeUser(ctx context.Context) error {
   419  	user, ok := types.UserFromContext(ctx)
   420  	if !ok {
   421  		return fmt.Errorf("user struct not found in context")
   422  	}
   423  
   424  	eaRoles, err := as.getRolesForUser(ctx, user)
   425  	if err != nil {
   426  		return err
   427  	}
   428  
   429  	return as.authorizeUser(eaRoles)
   430  }
   431  

View as plain text