Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactors Initiative FE to support Authz + Anon Access #164

Merged
merged 5 commits into from
Jan 24, 2024
Merged
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
35 changes: 34 additions & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"log"
"net/http"
"os"
"regexp"
"strings"
"time"

Expand Down Expand Up @@ -314,7 +315,7 @@ func run(args []string) error {
zaphttplog.NewMiddleware(logger, zaphttplog.WithConcise(false)),
chimiddleware.Recoverer,
jwtauth.Verifier(jwtauth.New("EdDSA", nil, jwKey)),
jwtauth.Authenticator,
requireJWTIfNotPublicEndpoint,
session.WithAuthn(logger, db),
}, addl...)
}
Expand Down Expand Up @@ -372,6 +373,34 @@ func run(args []string) error {
return nil
}

type allowFn func(r *http.Request) bool

var publicEndpoints = []allowFn{
allowPublicInitiativeLookups,
}

var allowPublicInitiativeLookupsRegexp = regexp.MustCompile(`^/initiative/[^/]*$`)

func allowPublicInitiativeLookups(r *http.Request) bool {
if r.Method != http.MethodGet {
return false
}
return allowPublicInitiativeLookupsRegexp.MatchString(r.URL.Path)
}

func requireJWTIfNotPublicEndpoint(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, fn := range publicEndpoints {
if fn(r) {
r = r.WithContext(session.WithAllowedAnonymous(r.Context()))
next.ServeHTTP(w, r)
return
}
}
jwtauth.Authenticator(next).ServeHTTP(w, r)
})
}

func rateLimitMiddleware(maxReq int, windowLength time.Duration) func(http.Handler) http.Handler {
// This example uses an in-memory rate limiter for simplicity, an application
// that will be running multiple API instances should likely use something like
Expand All @@ -381,6 +410,10 @@ func rateLimitMiddleware(maxReq int, windowLength time.Duration) func(http.Handl
maxReq,
windowLength,
httprate.WithKeyFuncs(func(r *http.Request) (string, error) {
if session.IsAllowedAnonymous(r.Context()) {
// Rate limit by IP address if the user is anonymous.
return r.RemoteAddr, nil
}
_, claims, err := jwtauth.FromContext(r.Context())
if err != nil {
return "", fmt.Errorf("failed to get claims from context: %w", err)
Expand Down
2 changes: 1 addition & 1 deletion cmd/server/pactasrv/analysis.go
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ type initiativeAnalysis struct {

func (ia *initiativeAnalysis) checkAuth(ctx context.Context, tx db.Tx) error {
// This crudely tests whether or not a user is a manager of the initiative.
if err := ia.s.initiativeDoAuthzAndAuditLog(ctx, ia.iID, pacta.AuditLogAction_Update); err != nil {
if _, err := ia.s.initiativeDoAuthzAndAuditLog(ctx, ia.iID, pacta.AuditLogAction_Update); err != nil {
return err
}
i, err := ia.s.DB.Initiative(tx, ia.iID)
Expand Down
11 changes: 11 additions & 0 deletions cmd/server/pactasrv/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,26 @@ func notFoundOrUnauthorized[T ~string](actorInfo actorInfo, action pacta.AuditLo
}

func (s *Server) auditLogIfAuthorizedOrFail(ctx context.Context, status *authzStatus) error {
zapFields := func(others ...zap.Field) []zap.Field {
return append([]zap.Field{
zap.String("actor_id", status.actorUserID()),
zap.String("action", string(status.action)),
zap.String("target_type", string(status.primaryTargetType)),
zap.String("target_id", status.primaryTargetID),
}, others...)
}
if !status.isAuthorized {
s.Logger.Warn("not authorized", zapFields(zap.String("reason", "is_authorized_false"))...)
return notFoundOrUnauthorized(status.actorInfo, status.action, status.primaryTargetType, status.primaryTargetID)
}
al, err := status.ToAuditLog()
if err != nil {
s.Logger.Warn("not authorized", zapFields(zap.String("reason", "to_audit_log_failure"), zap.Error(err))...)
return err
}
_, err = s.DB.CreateAuditLog(s.DB.NoTxn(ctx), al)
if err != nil {
s.Logger.Warn("not authorized", zapFields(zap.String("reason", "create_audit_log_failure"), zap.Error(err))...)
return oapierr.Internal("creating audit log failed", zap.Error(err))
}
return nil
Expand Down
18 changes: 15 additions & 3 deletions cmd/server/pactasrv/conv/helpers.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package conv

import "time"
import (
"fmt"
"time"

"github.com/RMI/pacta/oapierr"
"go.uber.org/zap"
)

func ptr[T any](t T) *T {
return &t
Expand Down Expand Up @@ -52,10 +58,16 @@ func convAll[I any, O any](is []I, f func(I) (O, error)) ([]O, error) {
return os, nil
}

func dereferenceAll[T any](ts []*T) []T {
func dereference[T any](ts []*T, e error) ([]T, error) {
if e != nil {
return nil, e
}
result := make([]T, len(ts))
for i, t := range ts {
if t == nil {
return nil, oapierr.Internal("nil pointer found in derference", zap.String("type", fmt.Sprintf("%T", t)), zap.Int("index", i))
}
result[i] = *t
}
return result
return result, nil
}
11 changes: 8 additions & 3 deletions cmd/server/pactasrv/conv/pacta_to_oapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ func InitiativeToOAPI(i *pacta.Initiative) (*api.Initiative, error) {
if err != nil {
return nil, oapierr.Internal("initiativeToOAPI: portfolioInitiativeMembershipToOAPIInitiative failed", zap.Error(err))
}
iurs, err := dereference(convAll(i.InitiativeUserRelationships, InitiativeUserRelationshipToOAPI))
if err != nil {
return nil, oapierr.Internal("initiativeToOAPI: initiativeUserRelationshipToOAPI failed", zap.Error(err))
}
lang, err := LanguageToOAPI(i.Language)
if err != nil {
return nil, oapierr.Internal("initiativeToOAPI: languageToOAPI failed", zap.Error(err))
Expand All @@ -59,6 +63,7 @@ func InitiativeToOAPI(i *pacta.Initiative) (*api.Initiative, error) {
PublicDescription: i.PublicDescription,
RequiresInvitationToJoin: i.RequiresInvitationToJoin,
PortfolioInitiativeMemberships: pims,
InitiativeUserRelationships: iurs,
}, nil
}

Expand Down Expand Up @@ -250,7 +255,7 @@ func PortfolioToOAPI(p *pacta.Portfolio) (*api.Portfolio, error) {
}
pims, err := convAll(p.PortfolioInitiativeMemberships, portfolioInitiativeMembershipToOAPIInitiative)
if err != nil {
return nil, oapierr.Internal("initiativeToOAPI: portfolioInitiativeMembershipToOAPIInitiative failed", zap.Error(err))
return nil, oapierr.Internal("portfolioToOAPI: portfolioInitiativeMembershipToOAPIInitiative failed", zap.Error(err))
}
hd, err := HoldingsDateToOAPI(p.Properties.HoldingsDate)
if err != nil {
Expand Down Expand Up @@ -408,7 +413,7 @@ func AnalysisToOAPI(a *pacta.Analysis) (*api.Analysis, error) {
if a == nil {
return nil, oapierr.Internal("analysisToOAPI: can't convert nil pointer")
}
aas, err := convAll(a.Artifacts, AnalysisArtifactToOAPI)
aas, err := dereference(convAll(a.Artifacts, AnalysisArtifactToOAPI))
if err != nil {
return nil, oapierr.Internal("analysisToOAPI: analysisArtifactsToOAPI failed", zap.Error(err))
}
Expand Down Expand Up @@ -440,7 +445,7 @@ func AnalysisToOAPI(a *pacta.Analysis) (*api.Analysis, error) {
CompletedAt: timeToNilable(a.CompletedAt),
FailureCode: fc,
FailureMessage: fm,
Artifacts: dereferenceAll(aas),
Artifacts: aas,
OwnerId: string(a.Owner.ID),
}, nil
}
Expand Down
107 changes: 74 additions & 33 deletions cmd/server/pactasrv/initiative.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func (s *Server) CreateInitiative(ctx context.Context, request api.CreateInitiat
// (PATCH /initiative/{id})
func (s *Server) UpdateInitiative(ctx context.Context, request api.UpdateInitiativeRequestObject) (api.UpdateInitiativeResponseObject, error) {
id := pacta.InitiativeID(request.Id)
if err := s.initiativeDoAuthzAndAuditLog(ctx, id, pacta.AuditLogAction_Update); err != nil {
if _, err := s.initiativeDoAuthzAndAuditLog(ctx, id, pacta.AuditLogAction_Update); err != nil {
return nil, err
}
mutations := []db.UpdateInitiativeFn{}
Expand Down Expand Up @@ -97,7 +97,7 @@ func (s *Server) UpdateInitiative(ctx context.Context, request api.UpdateInitiat
// (DELETE /initiative/{id})
func (s *Server) DeleteInitiative(ctx context.Context, request api.DeleteInitiativeRequestObject) (api.DeleteInitiativeResponseObject, error) {
id := pacta.InitiativeID(request.Id)
if err := s.initiativeDoAuthzAndAuditLog(ctx, id, pacta.AuditLogAction_Delete); err != nil {
if _, err := s.initiativeDoAuthzAndAuditLog(ctx, id, pacta.AuditLogAction_Delete); err != nil {
return nil, err
}
buris, err := s.DB.DeleteInitiative(s.DB.NoTxn(ctx), id)
Expand All @@ -113,9 +113,13 @@ func (s *Server) DeleteInitiative(ctx context.Context, request api.DeleteInitiat
// Returns an initiative by ID
// (GET /initiative/{id})
func (s *Server) FindInitiativeById(ctx context.Context, request api.FindInitiativeByIdRequestObject) (api.FindInitiativeByIdResponseObject, error) {
// TODO(#12) Allow Anonymous Access
actorInfo, err := s.getActorInfoOrAnon(ctx)
if err != nil {
return nil, err
}
id := pacta.InitiativeID(request.Id)
if err := s.initiativeDoAuthzAndAuditLog(ctx, id, pacta.AuditLogAction_ReadMetadata); err != nil {
info, err := s.initiativeDoAuthzAndAuditLog(ctx, id, pacta.AuditLogAction_ReadMetadata)
if err != nil {
return nil, err
}
i, err := s.DB.Initiative(s.DB.NoTxn(ctx), id)
Expand All @@ -125,11 +129,27 @@ func (s *Server) FindInitiativeById(ctx context.Context, request api.FindInitiat
}
return nil, oapierr.Internal("failed to load initiative", zap.String("initiative_id", request.Id), zap.Error(err))
}
portfolios, err := s.DB.PortfolioInitiativeMembershipsByInitiative(s.DB.NoTxn(ctx), i.ID)
if err != nil {
return nil, oapierr.Internal("failed to load portfolios for initiative", zap.String("initiative_id", string(i.ID)), zap.Error(err))
if info.CanManageUsersAndPortfolios {
portfolios, err := s.DB.PortfolioInitiativeMembershipsByInitiative(s.DB.NoTxn(ctx), i.ID)
if err != nil {
return nil, oapierr.Internal("failed to load portfolios for initiative", zap.String("initiative_id", string(i.ID)), zap.Error(err))
}
i.PortfolioInitiativeMemberships = portfolios
relationships, err := s.DB.InitiativeUserRelationshipsByInitiative(s.DB.NoTxn(ctx), i.ID)
if err != nil {
return nil, oapierr.Internal("failed to load initiative user relationships for initiative", zap.String("initiative_id", string(i.ID)), zap.Error(err))
}
i.InitiativeUserRelationships = relationships
} else if actorInfo.UserID != "" {
iur, err := s.DB.InitiativeUserRelationship(s.DB.NoTxn(ctx), i.ID, actorInfo.UserID)
if err != nil {
return nil, oapierr.Internal("failed to load singular initiative user relationship for initiative", zap.String("initiative_id", string(i.ID)), zap.Error(err))
}
i.InitiativeUserRelationships = []*pacta.InitiativeUserRelationship{iur}
}
if !info.CanSeeInternalInfo {
i.InternalDescription = ""
gbdubs marked this conversation as resolved.
Show resolved Hide resolved
}
i.PortfolioInitiativeMemberships = portfolios
resp, err := conv.InitiativeToOAPI(i)
if err != nil {
return nil, err
Expand All @@ -144,6 +164,9 @@ func (s *Server) ListInitiatives(ctx context.Context, request api.ListInitiative
if err != nil {
return nil, oapierr.Internal("failed to load all initiatives", zap.Error(err))
}
for _, i := range is {
i.InternalDescription = ""
}
result, err := dereference(mapAll(is, conv.InitiativeToOAPI))
if err != nil {
return nil, err
Expand All @@ -158,25 +181,22 @@ func (s *Server) AllInitiativeData(ctx context.Context, request api.AllInitiativ
if err != nil {
return nil, err
}
// TODO(#12) Implement Authorization, along the lines of #121
i, err := s.DB.Initiative(s.DB.NoTxn(ctx), pacta.InitiativeID(request.Id))
id := pacta.InitiativeID(request.Id)
_, err = s.initiativeDoAuthzAndAuditLog(ctx, id, pacta.AuditLogAction_Download)
if err != nil {
if db.IsNotFound(err) {
return nil, oapierr.NotFound("initiative not found", zap.String("initiative_id", request.Id))
}
return nil, oapierr.Internal("failed to load initiative", zap.String("initiative_id", request.Id), zap.Error(err))
return nil, err
}
portfolioMembers, err := s.DB.PortfolioInitiativeMembershipsByInitiative(s.DB.NoTxn(ctx), i.ID)
portfolioMembers, err := s.DB.PortfolioInitiativeMembershipsByInitiative(s.DB.NoTxn(ctx), id)
if err != nil {
return nil, oapierr.Internal("failed to load portfolio memberships for initiative", zap.String("initiative_id", string(i.ID)), zap.Error(err))
return nil, oapierr.Internal("failed to load portfolio memberships for initiative", zap.String("initiative_id", string(id)), zap.Error(err))
}
portfolioIDs := []pacta.PortfolioID{}
for _, pm := range portfolioMembers {
portfolioIDs = append(portfolioIDs, pm.Portfolio.ID)
}
portfolios, err := s.DB.Portfolios(s.DB.NoTxn(ctx), portfolioIDs)
if err != nil {
return nil, oapierr.Internal("failed to load portfolios for initiative", zap.String("initiative_id", string(i.ID)), zap.Error(err))
return nil, oapierr.Internal("failed to load portfolios for initiative", zap.String("initiative_id", string(id)), zap.Error(err))
}
if err := s.populateBlobsInPortfolios(ctx, values(portfolios)...); err != nil {
return nil, err
Expand All @@ -192,7 +212,7 @@ func (s *Server) AllInitiativeData(ctx context.Context, request api.AllInitiativ
PrimaryTargetID: string(p.ID),
PrimaryTargetOwner: p.Owner,
SecondaryTargetType: pacta.AuditLogTargetType_Initiative,
SecondaryTargetID: string(i.ID),
SecondaryTargetID: string(id),
SecondaryTargetOwner: &pacta.Owner{ID: "SYSTEM"}, // TODO(#12) When merging with #121, use the const type.
})
}
Expand All @@ -216,24 +236,30 @@ func (s *Server) AllInitiativeData(ctx context.Context, request api.AllInitiativ
ExpirationTime: expiryTime,
})
}

return api.AllInitiativeData200JSONResponse(response), nil
}

func (s *Server) initiativeDoAuthzAndAuditLog(ctx context.Context, iID pacta.InitiativeID, action pacta.AuditLogAction) error {
actorInfo, err := s.getActorInfoOrErrIfAnon(ctx)
if err != nil {
return err
}
iurs, err := s.DB.InitiativeUserRelationshipsByInitiative(s.DB.NoTxn(ctx), iID)
type initiativeAuthzVisibilityInfo struct {
CanManageUsersAndPortfolios bool
CanSeeInternalInfo bool
}

func (s *Server) initiativeDoAuthzAndAuditLog(ctx context.Context, iID pacta.InitiativeID, action pacta.AuditLogAction) (*initiativeAuthzVisibilityInfo, error) {
actorInfo, err := s.getActorInfoOrAnon(ctx)
if err != nil {
return oapierr.Internal("failed to list initiative user relationships", zap.Error(err))
return nil, err
}
userIsInitiativeManager := false
for _, iur := range iurs {
if iur.User.ID == actorInfo.UserID && iur.Manager {
userIsInitiativeManager = true
break
userIsInitiativeMember := false
if actorInfo.UserID != "" {
iur, err := s.DB.InitiativeUserRelationship(s.DB.NoTxn(ctx), iID, actorInfo.UserID)
if err != nil {
if !db.IsNotFound(err) {
return nil, oapierr.Internal("failed to find initiative user relationships", zap.Error(err))
}
} else {
userIsInitiativeMember = iur.Member
userIsInitiativeManager = iur.Manager
}
}
as := &authzStatus{
Expand All @@ -244,15 +270,30 @@ func (s *Server) initiativeDoAuthzAndAuditLog(ctx context.Context, iID pacta.Ini
action: action,
}
switch action {
case pacta.AuditLogAction_Delete, pacta.AuditLogAction_Create, pacta.AuditLogAction_ReadMetadata, pacta.AuditLogAction_Update:
case pacta.AuditLogAction_ReadMetadata:
as.isAuthorized = true
if userIsInitiativeManager {
as.authorizedAsActorType = ptr(pacta.AuditLogActorType_Owner)
} else if actorInfo.IsAdmin || actorInfo.IsSuperAdmin {
as.authorizedAsActorType = ptr(pacta.AuditLogActorType_Admin)
} else {
as.authorizedAsActorType = ptr(pacta.AuditLogActorType_Public)
}
case pacta.AuditLogAction_Delete, pacta.AuditLogAction_Create, pacta.AuditLogAction_Update:
if userIsInitiativeManager {
as.authorizedAsActorType = ptr(pacta.AuditLogActorType_Owner)
as.isAuthorized = true
} else {
as.isAuthorized, as.authorizedAsActorType = allowIfAdmin(actorInfo)
}
default:
return fmt.Errorf("unknown action %q for initiative authz", action)
return nil, fmt.Errorf("unknown action %q for initiative authz", action)
}
if err := s.auditLogIfAuthorizedOrFail(ctx, as); err != nil {
return nil, err
}
return s.auditLogIfAuthorizedOrFail(ctx, as)
return &initiativeAuthzVisibilityInfo{
CanSeeInternalInfo: userIsInitiativeMember || userIsInitiativeManager || actorInfo.IsAdmin || actorInfo.IsSuperAdmin,
CanManageUsersAndPortfolios: userIsInitiativeManager || actorInfo.IsAdmin || actorInfo.IsSuperAdmin,
}, nil
}
Loading
Loading