diff --git a/README.md b/README.md index 110eaf145..0b99662bf 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Hermes was created and is currently maintained by HashiCorp Labs, a small team i 1. Enable the following APIs for [Google Workspace APIs](https://developers.google.com/workspace/guides/enable-apis) + - Admin SDK API - Google Docs API - Google Drive API - Gmail API diff --git a/internal/api/v2/approvals.go b/internal/api/v2/approvals.go index d0b0d8a30..25e15e9be 100644 --- a/internal/api/v2/approvals.go +++ b/internal/api/v2/approvals.go @@ -15,87 +15,104 @@ import ( func ApprovalsHandler(srv server.Server) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "DELETE": - // Validate request. - docID, err := parseResourceIDFromURL(r.URL.Path, "approvals") - if err != nil { - srv.Logger.Error("error parsing document ID", - "error", err, - "method", r.Method, - "path", r.URL.Path, - ) - http.Error(w, "Document ID not found", http.StatusNotFound) - return - } + // Validate request. + docID, err := parseResourceIDFromURL(r.URL.Path, "approvals") + if err != nil { + srv.Logger.Error("error parsing document ID", + "error", err, + "method", r.Method, + "path", r.URL.Path, + ) + http.Error(w, "Document ID not found", http.StatusNotFound) + return + } - // Check if document is locked. - locked, err := hcd.IsLocked(docID, srv.DB, srv.GWService, srv.Logger) - if err != nil { - srv.Logger.Error("error checking document locked status", - "error", err, - "path", r.URL.Path, - "method", r.Method, - "doc_id", docID, - ) - http.Error(w, "Error getting document status", http.StatusNotFound) - return - } - // Don't continue if document is locked. - if locked { - http.Error(w, "Document is locked", http.StatusLocked) - return - } + // Check if document is locked. + locked, err := hcd.IsLocked(docID, srv.DB, srv.GWService, srv.Logger) + if err != nil { + srv.Logger.Error("error checking document locked status", + "error", err, + "path", r.URL.Path, + "method", r.Method, + "doc_id", docID, + ) + http.Error(w, "Error getting document status", http.StatusNotFound) + return + } + // Don't continue if document is locked. + if locked { + http.Error(w, "Document is locked", http.StatusLocked) + return + } - // Get document from database. - model := models.Document{ + // Get document from database. + model := models.Document{ + GoogleFileID: docID, + } + if err := model.Get(srv.DB); err != nil { + srv.Logger.Error("error getting document from database", + "error", err, + "path", r.URL.Path, + "method", r.Method, + "doc_id", docID, + ) + http.Error(w, "Error accessing document", + http.StatusInternalServerError) + return + } + + // Get reviews for the document. + var reviews models.DocumentReviews + if err := reviews.Find(srv.DB, models.DocumentReview{ + Document: models.Document{ GoogleFileID: docID, - } - if err := model.Get(srv.DB); err != nil { - srv.Logger.Error("error getting document from database", - "error", err, - "path", r.URL.Path, - "method", r.Method, - "doc_id", docID, - ) - http.Error(w, "Error accessing document", - http.StatusInternalServerError) - return - } + }, + }); err != nil { + srv.Logger.Error("error getting reviews for document", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docID, + ) + return + } - // Get reviews for the document. - var reviews models.DocumentReviews - if err := reviews.Find(srv.DB, models.DocumentReview{ - Document: models.Document{ - GoogleFileID: docID, - }, - }); err != nil { - srv.Logger.Error("error getting reviews for document", - "error", err, - "method", r.Method, - "path", r.URL.Path, - "doc_id", docID, - ) - return - } + // Get group reviews for the document. + var groupReviews models.DocumentGroupReviews + if err := groupReviews.Find(srv.DB, models.DocumentGroupReview{ + Document: models.Document{ + GoogleFileID: docID, + }, + }); err != nil { + srv.Logger.Error("error getting group reviews for document", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docID, + ) + return + } - // Convert database model to a document. - doc, err := document.NewFromDatabaseModel( - model, reviews) - if err != nil { - srv.Logger.Error("error converting database model to document type", - "error", err, - "method", r.Method, - "path", r.URL.Path, - "doc_id", docID, - ) - http.Error(w, "Error accessing document", - http.StatusInternalServerError) - return - } + // Convert database model to a document. + doc, err := document.NewFromDatabaseModel( + model, reviews, groupReviews) + if err != nil { + srv.Logger.Error("error converting database model to document type", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docID, + ) + http.Error(w, "Error accessing document", + http.StatusInternalServerError) + return + } + + userEmail := r.Context().Value("userEmail").(string) + switch r.Method { + case "DELETE": // Authorize request. - userEmail := r.Context().Value("userEmail").(string) if doc.Status != "In-Review" { http.Error(w, "Can only request changes of documents in the \"In-Review\" status", @@ -311,74 +328,60 @@ func ApprovalsHandler(srv server.Server) http.Handler { } }() - case "POST": - // Validate request. - docID, err := parseResourceIDFromURL(r.URL.Path, "approvals") - if err != nil { - srv.Logger.Error("error parsing document ID from approvals path", - "error", err, - "method", r.Method, - "path", r.URL.Path, - ) - http.Error(w, "Document ID not found", http.StatusNotFound) + case "OPTIONS": + // Document is not in review or approved status. + if doc.Status != "In-Review" && doc.Status != "Approved" { + w.Header().Set("Allowed", "") return } - // Check if document is locked. - locked, err := hcd.IsLocked(docID, srv.DB, srv.GWService, srv.Logger) - if err != nil { - srv.Logger.Error("error checking document locked status", - "error", err, - "path", r.URL.Path, - "method", r.Method, - "doc_id", docID, - ) - http.Error(w, "Error getting document status", http.StatusNotFound) - return - } - // Don't continue if document is locked. - if locked { - http.Error(w, "Document is locked", http.StatusLocked) + // Document already approved by user. + if contains(doc.ApprovedBy, userEmail) { + w.Header().Set("Allowed", "") return } - // Get document from database. - model := models.Document{ - GoogleFileID: docID, - } - if err := model.Get(srv.DB); err != nil { - srv.Logger.Error("error getting document from database", + // User is not an approver or in an approver group. + inApproverGroup, err := isUserInGroups( + userEmail, doc.ApproverGroups, srv.GWService) + if err != nil { + srv.Logger.Error("error calculating if user is in an approver group", "error", err, - "path", r.URL.Path, "method", r.Method, + "path", r.URL.Path, "doc_id", docID, ) http.Error(w, "Error accessing document", http.StatusInternalServerError) return } - - // Get reviews for the document. - var reviews models.DocumentReviews - if err := reviews.Find(srv.DB, models.DocumentReview{ - Document: models.Document{ - GoogleFileID: docID, - }, - }); err != nil { - srv.Logger.Error("error getting reviews for document", - "error", err, - "method", r.Method, - "path", r.URL.Path, - "doc_id", docID, - ) + if !contains(doc.Approvers, userEmail) && !inApproverGroup { + w.Header().Set("Allowed", "") return } - // Convert database model to a document. - doc, err := document.NewFromDatabaseModel( - model, reviews) + // User can approve. + w.Header().Set("Allowed", "POST") + return + + case "POST": + // Authorize request. + if doc.Status != "In-Review" && doc.Status != "Approved" { + http.Error(w, + `Document status must be "In-Review" or "Approved" to approve`, + http.StatusBadRequest) + return + } + if contains(doc.ApprovedBy, userEmail) { + http.Error(w, + "Document already approved by user", + http.StatusBadRequest) + return + } + inApproverGroup, err := isUserInGroups( + userEmail, doc.ApproverGroups, srv.GWService) if err != nil { - srv.Logger.Error("error converting database model to document type", + srv.Logger.Error("error calculating if user is in an approver group", "error", err, "method", r.Method, "path", r.URL.Path, @@ -388,27 +391,12 @@ func ApprovalsHandler(srv server.Server) http.Handler { http.StatusInternalServerError) return } - - // Authorize request. - userEmail := r.Context().Value("userEmail").(string) - if doc.Status != "In-Review" && doc.Status != "Approved" { - http.Error(w, - `Document status must be "In-Review" or "Approved" to approve`, - http.StatusBadRequest) - return - } - if !contains(doc.Approvers, userEmail) { + if !contains(doc.Approvers, userEmail) && !inApproverGroup { http.Error(w, "Not authorized as a document approver", http.StatusUnauthorized) return } - if contains(doc.ApprovedBy, userEmail) { - http.Error(w, - "Document already approved by user", - http.StatusBadRequest) - return - } // Add email to slice of users who have approved the document. doc.ApprovedBy = append(doc.ApprovedBy, userEmail) diff --git a/internal/api/v2/documents.go b/internal/api/v2/documents.go index 0fddfc84e..e24db0c05 100644 --- a/internal/api/v2/documents.go +++ b/internal/api/v2/documents.go @@ -21,12 +21,13 @@ import ( // DocumentPatchRequest contains a subset of documents fields that are allowed // to be updated with a PATCH request. type DocumentPatchRequest struct { - Approvers *[]string `json:"approvers,omitempty"` - Contributors *[]string `json:"contributors,omitempty"` - CustomFields *[]document.CustomField `json:"customFields,omitempty"` - Owners *[]string `json:"owners,omitempty"` - Status *string `json:"status,omitempty"` - Summary *string `json:"summary,omitempty"` + Approvers *[]string `json:"approvers,omitempty"` + ApproverGroups *[]string `json:"approverGroups,omitempty"` + Contributors *[]string `json:"contributors,omitempty"` + CustomFields *[]document.CustomField `json:"customFields,omitempty"` + Owners *[]string `json:"owners,omitempty"` + Status *string `json:"status,omitempty"` + Summary *string `json:"summary,omitempty"` // Tags []string `json:"tags,omitempty"` Title *string `json:"title,omitempty"` } @@ -98,9 +99,25 @@ func DocumentHandler(srv server.Server) http.Handler { return } + // Get group reviews for the document. + var groupReviews models.DocumentGroupReviews + if err := groupReviews.Find(srv.DB, models.DocumentGroupReview{ + Document: models.Document{ + GoogleFileID: docID, + }, + }); err != nil { + srv.Logger.Error("error getting group reviews for document", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docID, + ) + return + } + // Convert database model to a document. doc, err := document.NewFromDatabaseModel( - model, reviews) + model, reviews, groupReviews) if err != nil { srv.Logger.Error("error converting database model to document type", "error", err, @@ -443,12 +460,29 @@ func DocumentHandler(srv server.Server) http.Handler { // request. approversToEmail = compareSlices(doc.Approvers, *req.Approvers) } + if len(doc.ApproverGroups) == 0 && req.ApproverGroups != nil && + len(*req.ApproverGroups) != 0 { + // If there are no approver groups for the document, add all approver + // groups in the request. + approversToEmail = append(approversToEmail, *req.ApproverGroups...) + } else if req.ApproverGroups != nil && len(*req.ApproverGroups) != 0 { + // Only compare when there are stored approver groups and approver + // groups in the request. + approversToEmail = append( + approversToEmail, + compareSlices(doc.ApproverGroups, *req.ApproverGroups)..., + ) + } // Patch document (for Algolia). // Approvers. if req.Approvers != nil { doc.Approvers = *req.Approvers } + // Approver groups. + if req.ApproverGroups != nil { + doc.ApproverGroups = *req.ApproverGroups + } // Contributors. if req.Contributors != nil { doc.Contributors = *req.Contributors @@ -625,6 +659,18 @@ func DocumentHandler(srv server.Server) http.Handler { model.Approvers = approvers } + // Approver groups. + if req.ApproverGroups != nil { + approverGroups := make([]*models.Group, len(doc.ApproverGroups)) + for i, a := range doc.ApproverGroups { + g := models.Group{ + EmailAddress: a, + } + approverGroups[i] = &g + } + model.ApproverGroups = approverGroups + } + // Contributors. if req.Contributors != nil { var contributors []*models.User diff --git a/internal/api/v2/drafts.go b/internal/api/v2/drafts.go index 1446242a3..296c717bb 100644 --- a/internal/api/v2/drafts.go +++ b/internal/api/v2/drafts.go @@ -39,12 +39,13 @@ type DraftsRequest struct { // DraftsPatchRequest contains a subset of drafts fields that are allowed to // be updated with a PATCH request. type DraftsPatchRequest struct { - Approvers *[]string `json:"approvers,omitempty"` - Contributors *[]string `json:"contributors,omitempty"` - CustomFields *[]document.CustomField `json:"customFields,omitempty"` - Owners *[]string `json:"owners,omitempty"` - Product *string `json:"product,omitempty"` - Summary *string `json:"summary,omitempty"` + Approvers *[]string `json:"approvers,omitempty"` + ApproverGroups *[]string `json:"approverGroups,omitempty"` + Contributors *[]string `json:"contributors,omitempty"` + CustomFields *[]document.CustomField `json:"customFields,omitempty"` + Owners *[]string `json:"owners,omitempty"` + Product *string `json:"product,omitempty"` + Summary *string `json:"summary,omitempty"` // Tags []string `json:"tags,omitempty"` Title *string `json:"title,omitempty"` } @@ -660,9 +661,25 @@ func DraftsDocumentHandler(srv server.Server) http.Handler { return } + // Get group reviews for the document. + var groupReviews models.DocumentGroupReviews + if err := groupReviews.Find(srv.DB, models.DocumentGroupReview{ + Document: models.Document{ + GoogleFileID: docID, + }, + }); err != nil { + srv.Logger.Error("error getting group reviews for document", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docID, + ) + return + } + // Convert database model to a document. doc, err := document.NewFromDatabaseModel( - model, reviews) + model, reviews, groupReviews) if err != nil { srv.Logger.Error("error converting database model to document type", "error", err, @@ -1161,6 +1178,20 @@ func DraftsDocumentHandler(srv server.Server) http.Handler { model.Approvers = approvers } + // Approver groups. + if req.ApproverGroups != nil { + doc.ApproverGroups = *req.ApproverGroups + + approverGroups := make([]*models.Group, len(doc.ApproverGroups)) + for i, a := range doc.ApproverGroups { + g := models.Group{ + EmailAddress: a, + } + approverGroups[i] = &g + } + model.ApproverGroups = approverGroups + } + // Contributors. if req.Contributors != nil { doc.Contributors = *req.Contributors diff --git a/internal/api/v2/helpers.go b/internal/api/v2/helpers.go index 2de4bc28f..710bb2b58 100644 --- a/internal/api/v2/helpers.go +++ b/internal/api/v2/helpers.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/hashicorp-forge/hermes/internal/config" + gw "github.com/hashicorp-forge/hermes/pkg/googleworkspace" "github.com/hashicorp-forge/hermes/pkg/models" "github.com/hashicorp/go-hclog" "github.com/hashicorp/go-multierror" @@ -530,6 +531,27 @@ func CompareAlgoliaAndDatabaseDocument( return result.ErrorOrNil() } +// isUserInGroups returns true if a user is in any supplied groups, false +// otherwise. +func isUserInGroups( + userEmail string, groupEmails []string, svc *gw.Service) (bool, error) { + // Get groups for user. + userGroups, err := svc.AdminDirectory.Groups.List(). + UserKey(userEmail). + Do() + if err != nil { + return false, fmt.Errorf("error getting groups for user: %w", err) + } + + for _, g := range userGroups.Groups { + if contains(groupEmails, g.Email) { + return true, nil + } + } + + return false, nil +} + func getBooleanValue(in map[string]any, key string) (bool, error) { var result bool diff --git a/internal/api/v2/projects_related_resources.go b/internal/api/v2/projects_related_resources.go index ec6375b52..d4b666340 100644 --- a/internal/api/v2/projects_related_resources.go +++ b/internal/api/v2/projects_related_resources.go @@ -113,7 +113,7 @@ func projectsResourceRelatedResourcesHandler( // Convert database model to a document. We don't need document review // data for this endpoint. doc, err := document.NewFromDatabaseModel( - hdrr.Document, models.DocumentReviews{}) + hdrr.Document, models.DocumentReviews{}, models.DocumentGroupReviews{}) if err != nil { srv.Logger.Error("error converting database model to document type", append([]interface{}{ diff --git a/internal/api/v2/reviews.go b/internal/api/v2/reviews.go index 265b2ea2f..aac28f7f7 100644 --- a/internal/api/v2/reviews.go +++ b/internal/api/v2/reviews.go @@ -101,9 +101,25 @@ func ReviewsHandler(srv server.Server) http.Handler { return } + // Get group reviews for the document. + var groupReviews models.DocumentGroupReviews + if err := groupReviews.Find(srv.DB, models.DocumentGroupReview{ + Document: models.Document{ + GoogleFileID: docID, + }, + }); err != nil { + srv.Logger.Error("error getting group reviews for document", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docID, + ) + return + } + // Convert database model to a document. doc, err := document.NewFromDatabaseModel( - model, reviews) + model, reviews, groupReviews) if err != nil { srv.Logger.Error("error converting database model to document type", "error", err, @@ -487,8 +503,12 @@ func ReviewsHandler(srv server.Server) http.Handler { return } - // Give document approvers edit access to the document. - for _, a := range doc.Approvers { + // Create slice of all approvers consisting of individuals and groups. + allApprovers := append(doc.Approvers, doc.ApproverGroups...) + + // Give document approvers and approver groups edit access to the + // document. + for _, a := range allApprovers { if err := srv.GWService.ShareFile(docID, a, "writer"); err != nil { srv.Logger.Error("error sharing file with approver", "error", err, @@ -533,10 +553,10 @@ func ReviewsHandler(srv server.Server) http.Handler { // Send emails to approvers, if enabled. if srv.Config.Email != nil && srv.Config.Email.Enabled { - if len(doc.Approvers) > 0 { + if len(allApprovers) > 0 { // TODO: use an asynchronous method for sending emails because we // can't currently recover gracefully from a failure here. - for _, approverEmail := range doc.Approvers { + for _, approverEmail := range allApprovers { err := email.SendReviewRequestedEmail( email.ReviewRequestedEmailData{ BaseURL: srv.Config.BaseURL, diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 9d5ff1059..b77478717 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -432,6 +432,20 @@ func (idx *Indexer) Run() error { os.Exit(1) } + // Get group reviews for the document. + var groupReviews models.DocumentGroupReviews + if err := groupReviews.Find(idx.Database, models.DocumentGroupReview{ + Document: models.Document{ + GoogleFileID: file.Id, + }, + }); err != nil { + log.Error("error getting group reviews for document", + "error", err, + "google_file_id", file.Id, + ) + os.Exit(1) + } + // Parse document modified time. modifiedTime, err := time.Parse(time.RFC3339Nano, file.ModifiedTime) if err != nil { @@ -451,7 +465,7 @@ func (idx *Indexer) Run() error { var doc *document.Document if idx.UseDatabaseForDocumentData { // Convert database record to a document. - doc, err = document.NewFromDatabaseModel(dbDoc, reviews) + doc, err = document.NewFromDatabaseModel(dbDoc, reviews, groupReviews) if err != nil { log.Error("error converting database record to document", "error", err, diff --git a/internal/indexer/refresh_headers.go b/internal/indexer/refresh_headers.go index a8939bade..4b15a1ccb 100644 --- a/internal/indexer/refresh_headers.go +++ b/internal/indexer/refresh_headers.go @@ -201,9 +201,23 @@ func refreshDocumentHeader( os.Exit(1) } + // Get group reviews for the document. + var groupReviews models.DocumentGroupReviews + if err := groupReviews.Find(idx.Database, models.DocumentGroupReview{ + Document: models.Document{ + GoogleFileID: file.Id, + }, + }); err != nil { + log.Error("error getting group reviews for document", + "error", err, + "google_file_id", file.Id, + ) + os.Exit(1) + } + // Convert database record to a document. doc, err = document.NewFromDatabaseModel( - model, reviews) + model, reviews, groupReviews) if err != nil { log.Error("error converting database record to document", "error", err, diff --git a/pkg/document/document.go b/pkg/document/document.go index f4cbd9c0b..9fec4f793 100644 --- a/pkg/document/document.go +++ b/pkg/document/document.go @@ -44,6 +44,10 @@ type Document struct { // are requested for the document. Approvers []string `json:"approvers,omitempty"` + // ApproverGroups is a slice of email address strings for groups whose + // approvals are requested for the document. + ApproverGroups []string `json:"approverGroups,omitempty"` + // ChangesRequestedBy is a slice of email address strings for users that have // requested changes for the document. ChangesRequestedBy []string `json:"changesRequestedBy,omitempty"` @@ -234,7 +238,9 @@ func NewFromAlgoliaObject( // NewFromDatabaseModel creates a document from a document database model. func NewFromDatabaseModel( - model models.Document, reviews models.DocumentReviews, + model models.Document, + reviews models.DocumentReviews, + groupReviews models.DocumentGroupReviews, ) (*Document, error) { doc := &Document{} @@ -273,6 +279,13 @@ func NewFromDatabaseModel( doc.Approvers = approvers doc.ChangesRequestedBy = changesRequestedBy + // ApproverGroups. + var approverGroups []string + for _, r := range groupReviews { + approverGroups = append(approverGroups, r.Group.EmailAddress) + } + doc.ApproverGroups = approverGroups + // Contributors. contributors := []string{} for _, c := range model.Contributors { @@ -601,6 +614,20 @@ func (d Document) ToDatabaseModels( } doc.Approvers = approvers + // Approver groups. + var approverGroups []*models.Group + for _, a := range d.ApproverGroups { + g := models.Group{ + EmailAddress: a, + } + // Validate email address. + if _, err := mail.ParseAddress(g.EmailAddress); err != nil { + continue + } + approverGroups = append(approverGroups, &g) + } + doc.ApproverGroups = approverGroups + return doc, reviews, nil } diff --git a/pkg/document/replace_header.go b/pkg/document/replace_header.go index e3ea6430b..bb51069af 100644 --- a/pkg/document/replace_header.go +++ b/pkg/document/replace_header.go @@ -575,7 +575,8 @@ func (doc *Document) ReplaceHeader( // Approvers cell. // Build approvers slice with a check next to reviewers who have approved. - var approvers []string + // Approver groups are listed first. + approvers := doc.ApproverGroups for _, approver := range doc.Approvers { if helpers.StringSliceContains(doc.ApprovedBy, approver) { approvers = append(approvers, "✅ "+approver) diff --git a/pkg/googleworkspace/service.go b/pkg/googleworkspace/service.go index 4275f94af..f8e82c7f8 100644 --- a/pkg/googleworkspace/service.go +++ b/pkg/googleworkspace/service.go @@ -14,6 +14,7 @@ import ( "golang.org/x/oauth2/jwt" "github.com/pkg/browser" + directory "google.golang.org/api/admin/directory/v1" "google.golang.org/api/docs/v1" "google.golang.org/api/drive/v3" "google.golang.org/api/gmail/v1" @@ -24,11 +25,12 @@ import ( // Service provides access to the Google Workspace API. type Service struct { - Docs *docs.Service - Drive *drive.Service - Gmail *gmail.Service - OAuth2 *oauth2api.Service - People *people.PeopleService + AdminDirectory *directory.Service + Docs *docs.Service + Drive *drive.Service + Gmail *gmail.Service + OAuth2 *oauth2api.Service + People *people.PeopleService } // Config is the configuration for interacting with Google Workspace using a @@ -50,6 +52,7 @@ func NewFromConfig(cfg *Config) *Service { Email: cfg.ClientEmail, PrivateKey: []byte(cfg.PrivateKey), Scopes: []string{ + "https://www.googleapis.com/auth/admin.directory.group.readonly", "https://www.googleapis.com/auth/directory.readonly", "https://www.googleapis.com/auth/documents", "https://www.googleapis.com/auth/drive", @@ -60,6 +63,10 @@ func NewFromConfig(cfg *Config) *Service { } client := conf.Client(context.TODO()) + adminDirectorySrv, err := directory.NewService(context.TODO(), option.WithHTTPClient(client)) + if err != nil { + log.Fatalf("Unable to retrieve Admin Directory client: %v", err) + } docSrv, err := docs.NewService(context.TODO(), option.WithHTTPClient(client)) if err != nil { log.Fatalf("Unable to retrieve Docs client: %v", err) @@ -83,11 +90,12 @@ func NewFromConfig(cfg *Config) *Service { peoplePeopleSrv := people.NewPeopleService(peopleSrv) return &Service{ - Docs: docSrv, - Drive: driveSrv, - Gmail: gmailSrv, - OAuth2: oAuth2Srv, - People: peoplePeopleSrv, + AdminDirectory: adminDirectorySrv, + Docs: docSrv, + Drive: driveSrv, + Gmail: gmailSrv, + OAuth2: oAuth2Srv, + People: peoplePeopleSrv, } } @@ -105,6 +113,7 @@ func New() *Service { // If modifying these scopes, delete your previously saved token.json. gc, err := google.ConfigFromJSON(b, + "https://www.googleapis.com/auth/admin.directory.group.readonly", "https://www.googleapis.com/auth/directory.readonly", "https://www.googleapis.com/auth/documents", "https://www.googleapis.com/auth/drive", @@ -114,6 +123,10 @@ func New() *Service { } client := getClient(gc) + adminDirectorySrv, err := directory.NewService(context.TODO(), option.WithHTTPClient(client)) + if err != nil { + log.Fatalf("Unable to retrieve Admin Directory client: %v", err) + } docSrv, err := docs.NewService(context.TODO(), option.WithHTTPClient(client)) if err != nil { log.Fatalf("Unable to retrieve Google Docs client: %v", err) @@ -137,11 +150,12 @@ func New() *Service { peoplePeopleSrv := people.NewPeopleService(peopleSrv) return &Service{ - Docs: docSrv, - Drive: driveSrv, - Gmail: gmailSrv, - OAuth2: oAuth2Srv, - People: peoplePeopleSrv, + AdminDirectory: adminDirectorySrv, + Docs: docSrv, + Drive: driveSrv, + Gmail: gmailSrv, + OAuth2: oAuth2Srv, + People: peoplePeopleSrv, } } diff --git a/pkg/models/document.go b/pkg/models/document.go index a61155309..13af6a753 100644 --- a/pkg/models/document.go +++ b/pkg/models/document.go @@ -21,6 +21,10 @@ type Document struct { // document. Approvers []*User `gorm:"many2many:document_reviews;"` + // ApproverGroups is the list of groups whose approval is requested for the + // document. + ApproverGroups []*Group `gorm:"many2many:document_group_reviews;"` + // Contributors are users who have contributed to the document. Contributors []*User `gorm:"many2many:document_contributors;"` @@ -527,6 +531,16 @@ func (d *Document) createAssocations(db *gorm.DB) error { } d.Approvers = approvers + // Find or create approver groups. + var approverGroups []*Group + for _, a := range d.ApproverGroups { + if err := a.FirstOrCreate(db); err != nil { + return fmt.Errorf("error finding or creating approver groups: %w", err) + } + approverGroups = append(approverGroups, a) + } + d.ApproverGroups = approverGroups + // Find or create contributors. var contributors []*User for _, c := range d.Contributors { @@ -576,6 +590,16 @@ func (d *Document) getAssociations(db *gorm.DB) error { } d.Approvers = approvers + // Get approver groups. + var approverGroups []*Group + for _, a := range d.ApproverGroups { + if err := a.Get(db); err != nil { + return fmt.Errorf("error getting approver group: %w", err) + } + approverGroups = append(approverGroups, a) + } + d.ApproverGroups = approverGroups + // Get contributors. var contributors []*User for _, c := range d.Contributors { @@ -653,6 +677,16 @@ func (d *Document) replaceAssocations(db *gorm.DB) error { return err } + // Replace approver groups. + if err := db. + Session(&gorm.Session{SkipHooks: true}). + Model(&d). + Unscoped(). + Association("ApproverGroups"). + Replace(d.ApproverGroups); err != nil { + return err + } + // Replace contributors. if err := db. Session(&gorm.Session{SkipHooks: true}). diff --git a/pkg/models/gorm.go b/pkg/models/gorm.go index c65e67a0f..9de28da49 100644 --- a/pkg/models/gorm.go +++ b/pkg/models/gorm.go @@ -6,11 +6,13 @@ func ModelsToAutoMigrate() []interface{} { &Document{}, &DocumentCustomField{}, &DocumentFileRevision{}, + DocumentGroupReview{}, &DocumentRelatedResource{}, &DocumentRelatedResourceExternalLink{}, &DocumentRelatedResourceHermesDocument{}, &DocumentReview{}, &DocumentTypeCustomField{}, + &Group{}, &IndexerFolder{}, &IndexerMetadata{}, &Product{}, diff --git a/pkg/models/group.go b/pkg/models/group.go new file mode 100644 index 000000000..ea5eb5ec3 --- /dev/null +++ b/pkg/models/group.go @@ -0,0 +1,65 @@ +package models + +import ( + validation "github.com/go-ozzo/ozzo-validation/v4" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// Group is a model for an application group. +type Group struct { + gorm.Model + + // EmailAddress is the email address of the group. + EmailAddress string `gorm:"default:null;index;not null;type:citext;unique"` +} + +// FirstOrCreate finds the first group by email address or creates a group +// record if it does not exist in database db. The result is saved back to the +// receiver. +func (g *Group) FirstOrCreate(db *gorm.DB) error { + if err := validation.ValidateStruct(g, + validation.Field( + &g.EmailAddress, validation.Required), + ); err != nil { + return err + } + + return db.Transaction(func(tx *gorm.DB) error { + if err := tx. + Where(Group{EmailAddress: g.EmailAddress}). + Omit(clause.Associations). + Clauses(clause.OnConflict{DoNothing: true}). + FirstOrCreate(&g). + Error; err != nil { + return err + } + + return nil + }) +} + +// Get gets a group from database db by email address, and assigns it to the +// receiver. +func (g *Group) Get(db *gorm.DB) error { + return db. + Where(Group{EmailAddress: g.EmailAddress}). + Preload(clause.Associations). + First(&g).Error +} + +// Upsert updates or inserts the receiver group into database db. +func (g *Group) Upsert(db *gorm.DB) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx. + Where(Group{EmailAddress: g.EmailAddress}). + Omit(clause.Associations). + Assign(*g). + FirstOrCreate(&g). + Error; err != nil { + return err + } + + return nil + }) +} diff --git a/pkg/models/group_test.go b/pkg/models/group_test.go new file mode 100644 index 000000000..379827dab --- /dev/null +++ b/pkg/models/group_test.go @@ -0,0 +1,65 @@ +package models + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGroupModel(t *testing.T) { + dsn := os.Getenv("HERMES_TEST_POSTGRESQL_DSN") + if dsn == "" { + t.Skip("HERMES_TEST_POSTGRESQL_DSN environment variable isn't set") + } + + t.Run("FirstOrCreate", func(t *testing.T) { + db, tearDownTest := setupTest(t, dsn) + defer tearDownTest(t) + + t.Run("Create first group", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + u := Group{ + EmailAddress: "a@a.com", + } + err := u.FirstOrCreate(db) + require.NoError(err) + assert.EqualValues(1, u.ID) + assert.Equal("a@a.com", u.EmailAddress) + }) + + t.Run("Get first group using FirstOrCreate", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + u := Group{ + EmailAddress: "a@a.com", + } + err := u.FirstOrCreate(db) + require.NoError(err) + assert.EqualValues(1, u.ID) + assert.Equal("a@a.com", u.EmailAddress) + }) + + t.Run("Create second group", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + u := Group{ + EmailAddress: "b@b.com", + } + err := u.FirstOrCreate(db) + require.NoError(err) + assert.EqualValues(2, u.ID) + assert.Equal("b@b.com", u.EmailAddress) + }) + + t.Run("Get second group using FirstOrCreate", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + u := Group{ + EmailAddress: "b@b.com", + } + err := u.FirstOrCreate(db) + require.NoError(err) + assert.EqualValues(2, u.ID) + assert.Equal("b@b.com", u.EmailAddress) + }) + }) +}