Skip to content

Commit

Permalink
Merge branch 'main' into jeffdaley/sidebar-cleanup
Browse files Browse the repository at this point in the history
  • Loading branch information
jeffdaley committed Apr 15, 2024
2 parents 81cb002 + 52bbe69 commit a4cc68e
Show file tree
Hide file tree
Showing 65 changed files with 1,913 additions and 317 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (optional, if enabling Google Groups as document approvers)
- Google Docs API
- Google Drive API
- Gmail API
Expand Down Expand Up @@ -145,12 +146,12 @@ NOTE: when not using a Google service account, this will automatically open a br

- Create a new key (JSON type) for the service account and download it.
- Go to [Delegating domain-wide authority to the service account](https://developers.google.com/identity/protocols/oauth2/service-account#delegatingauthority) and follow the instructions to enter the OAuth scopes.
- Add the following OAuth scopes (comma-delimited list):
- Add the following OAuth scopes (if enabling group approvals, add `https://www.googleapis.com/auth/admin.directory.group.readonly` to the comma-delimited list):
`https://www.googleapis.com/auth/directory.readonly,https://www.googleapis.com/auth/documents,https://www.googleapis.com/auth/drive,https://www.googleapis.com/auth/gmail.send`

1. Configure the service account in the `auth` block under the `google_workspace` config block.

More to come here...
1. If enabling group approvals, add the `https://www.googleapis.com/auth/admin.directory.group.readonly` role to the service user configured as the `subject` in the `auth` block (from previous step).

## Architecture

Expand Down
10 changes: 10 additions & 0 deletions configs/config.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@ google_workspace {
// drafts_folder contains all draft documents.
drafts_folder = "my-drafts-folder-id"

// group_approvals is the configuration for using Google Groups as document
// approvers.
group_approvals {
// enabled enables using Google Groups as document approvers.
enabled = false

// search_prefix is the prefix to use when searching for Google Groups.
// search_prefix = "team-"
}

// If create_doc_shortcuts is set to true, shortcuts_folder will contain an
// organized hierarchy of folders and shortcuts to published files that can be
// easily browsed directly in Google Drive:
Expand Down
290 changes: 150 additions & 140 deletions internal/api/v2/approvals.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -388,26 +391,33 @@ 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

// If the user is a group approver, they won't be in the approvers list.
if !contains(doc.Approvers, userEmail) {
doc.Approvers = append(doc.Approvers, userEmail)

// Add approver in database.
model.Approvers = append(model.Approvers, &models.User{
EmailAddress: userEmail,
})
if err := model.Upsert(srv.DB); err != nil {
srv.Logger.Error(
"error updating document in the database to add approver",
"error", err,
"method", r.Method,
"path", r.URL.Path,
"doc_id", docID,
)
http.Error(w, "Error approving document",
http.StatusInternalServerError)
return
}
}

// Add email to slice of users who have approved the document.
Expand Down Expand Up @@ -445,7 +455,7 @@ func ApprovalsHandler(srv server.Server) http.Handler {
"path", r.URL.Path,
"doc_id", docID,
"rev_id", latestRev.Id)
http.Error(w, "Error creating review",
http.Error(w, "Error approving document",
http.StatusInternalServerError)
return
}
Expand Down
Loading

0 comments on commit a4cc68e

Please sign in to comment.