Skip to content

Commit

Permalink
get grants
Browse files Browse the repository at this point in the history
  • Loading branch information
mgaeta committed Oct 1, 2024
1 parent 28a17db commit b877d09
Show file tree
Hide file tree
Showing 7 changed files with 393 additions and 11 deletions.
81 changes: 81 additions & 0 deletions pkg/connector/client/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,87 @@ type Publication struct {
Publisher string `json:"publisher"`
}

type Report []ReportEntry

type ReportConfigurations struct {
Audience string `json:"audience,omitempty"`
ContentType string `json:"contentType,omitempty"`
CsvPreferences ReportCsvPreferences `json:"csvPreferences,omitempty"`
Encrypt bool `json:"encrypt,omitempty"`
End time.Time `json:"end,omitempty"`
FileMask string `json:"fileMask,omitempty"`
FolderName string `json:"folderName,omitempty"`
FormatType string `json:"formatType,omitempty"`
IncludeMillisInFilename bool `json:"includeMillisInFilename,omitempty"`
IsFileRequiredInSftp bool `json:"isFileRequiredInSftp,omitempty"`
IsPgpFileExtnNotReqrd bool `json:"isPgpFileExtnNotReqrd,omitempty"`
Locale string `json:"locale,omitempty"`
Mapping string `json:"mapping,omitempty"`
Plugin string `json:"plugin,omitempty"`
SftpId string `json:"sftpId,omitempty"`
Sort ReportSort `json:"sort,omitempty"`
Start time.Time `json:"start,omitempty"`
Status string `json:"status,omitempty"`
Template string `json:"template,omitempty"`
TimeFrame string `json:"timeFrame,omitempty"`
TransformName string `json:"transformName,omitempty"`
Zip bool `json:"zip,omitempty"`
}

type ReportCsvPreferences struct {
ColumnDelimiter string `json:"columnDelimiter"`
Header bool `json:"header"`
HeaderForNoRecords bool `json:"headerForNoRecords"`
RowDelimiter string `json:"rowDelimiter"`
}

type ReportEntry struct {
Audience string `json:"audience"`
BusinessUnit string `json:"businessUnit"`
CompletedDate time.Time `json:"completedDate"`
ContentTitle string `json:"contentTitle"`
ContentType string `json:"contentType"`
ContentUUID string `json:"contentUuid"`
CostCenterCode string `json:"costCenterCode"`
CountryName string `json:"countryName"`
DepartmentCode string `json:"departmentCode"`
DepartmentOwner string `json:"departmentOwner"`
DeptName string `json:"deptName"`
DirectManagerName string `json:"directManagerName"`
Division string `json:"division"`
DivisionCode string `json:"divisionCode"`
DivisionOwner string `json:"divisonOwner"` // [SIC]
DurationHms string `json:"durationHms"`
EmailAddress string `json:"emailAddress"`
EmployeeClass string `json:"employeeClass"`
EmployeeId string `json:"employeeId"`
EstimatedDurationHms string `json:"estimatedDurationHms"`
FirstAccess time.Time `json:"firstAccess"`
FirstName string `json:"firstName"`
Geo string `json:"geo"`
HireDate string `json:"hireDate"`
HrbpOwner string `json:"hrbpOwner"`
IsAManager string `json:"isAManager"`
LanguageCode string `json:"languageCode"`
LastName string `json:"lastName"`
ManagerEmail string `json:"managerEmail"`
ManagerId string `json:"managerId"`
Status string `json:"status"`
UserId string `json:"userId"`
UserStatus string `json:"userStatus"`
UserUUID string `json:"userUuid"`
}

type ReportSort struct {
Field string `json:"field"`
Order string `json:"order"`
}

type ReportStatus struct {
Id string `json:"id"`
Status string `json:"status"`
}

type Skill struct {
LocaleCode string `json:"localeCode"`
Skills []string `json:"skills"`
Expand Down
118 changes: 110 additions & 8 deletions pkg/connector/client/percipio.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,37 @@ package client

import (
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
"time"

v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
"github.com/conductorone/baton-sdk/pkg/uhttp"
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
)

const (
BaseApiUrl = "https://api.percipio.com"
UsersListApiPath = "/user-management/v1/organizations/%s/users"
CoursesListApiPath = "/content-discovery/v2/organizations/%s/catalog-content"
PageSizeDefault = 1000
HeaderNameTotalCount = "x-total-count"
HeaderNamePagingRequestId = "x-paging-request-id"
ApiPathCoursesList = "/content-discovery/v2/organizations/%s/catalog-content"
ApiPathLearningActivityReport = "/reporting/v1/organizations/%s/report-requests/learning-activity"
ApiPathReport = "/reporting/v1/organizations/%s/report-requests/%s"
ApiPathUsersList = "/user-management/v1/organizations/%s/users"
BaseApiUrl = "https://api.percipio.com"
HeaderNamePagingRequestId = "x-paging-request-id"
HeaderNameTotalCount = "x-total-count"
PageSizeDefault = 1000
RetryAttemptsMaximum = 1000
ReportLookBackDefault = 10 * time.Hour * 24 * 365 // 10 years
RetryAfterDefault = 10 * time.Second // 10 seconds
)

type Client struct {
baseUrl *url.URL
bearerToken string
Cache StatusesStore
organizationId string
ReportStatus ReportStatus
wrapper *uhttp.BaseHttpClient
}

Expand All @@ -49,6 +59,7 @@ func New(
}

return &Client{
Cache: make(map[string]map[string]string),
baseUrl: parsedUrl,
bearerToken: token,
organizationId: organizationId,
Expand Down Expand Up @@ -76,7 +87,7 @@ func (c *Client) GetUsers(
"offset": offset,
}
var target []User
response, ratelimitData, err := c.get(ctx, UsersListApiPath, query, &target)
response, ratelimitData, err := c.get(ctx, ApiPathUsersList, query, &target)
if err != nil {
return nil, 0, ratelimitData, err
}
Expand Down Expand Up @@ -118,7 +129,7 @@ func (c *Client) GetCourses(
"pagingRequestId": pagingRequestId,
}
var target []Course
response, ratelimitData, err := c.get(ctx, CoursesListApiPath, query, &target)
response, ratelimitData, err := c.get(ctx, ApiPathCoursesList, query, &target)
if err != nil {
return nil, "", 0, ratelimitData, err
}
Expand All @@ -133,3 +144,94 @@ func (c *Client) GetCourses(
pagingRequestId = response.Header.Get(HeaderNamePagingRequestId)
return target, pagingRequestId, total, ratelimitData, nil
}

// GenerateLearningActivityReport makes a post request to the API asking it to
// start generating a report. We'll need to then poll a _different_ endpoint to
// get the actual report data.
func (c *Client) GenerateLearningActivityReport(
ctx context.Context,
) (
*v2.RateLimitDescription,
error,
) {
now := time.Now()
body := ReportConfigurations{
Start: now.Add(-ReportLookBackDefault),
End: now,
// TODO MARCOS 1 pick default configurations.
}

var target ReportStatus
response, ratelimitData, err := c.post(
ctx,
ApiPathLearningActivityReport,
body,
&target,
)
if err != nil {
return ratelimitData, err
}
defer response.Body.Close()

// Should include ID and "PENDING".
c.ReportStatus = target

return ratelimitData, nil
}

func (c *Client) GetLearningActivityReport(
ctx context.Context,
) (
*v2.RateLimitDescription,
error,
) {
var (
ratelimitData *v2.RateLimitDescription
target Report
)

for i := 0; i < RetryAttemptsMaximum; i++ {
// While the report is still processing, we get this ReportStatus
// object. Once we actually get data, it'll return an array of rows.
response, ratelimitData0, err := c.get(
ctx,
// Punt setting `organizationId`, it is added in `doRequest()`.
fmt.Sprintf(ApiPathReport, "%s", c.ReportStatus.Id),
nil,
&target,
)
ratelimitData = ratelimitData0
if err != nil {
if response == nil {
return nil, fmt.Errorf("got no response")
}
// If we got an error unmarshalling, it might be because the report
// is still being generated. If that's the case, try unmarshalling
// with the expected shape.
var bodyBytes []byte
_, err := response.Body.Read(bodyBytes)
if err != nil {
return nil, err
}

err = json.Unmarshal(bodyBytes, &c.ReportStatus)
if err != nil {
return nil, err
}

time.Sleep(RetryAfterDefault)
continue
}

// We got the report object.
defer response.Body.Close()
break
}

c.ReportStatus.Status = "done"
err := c.Cache.Load(target)

Check failure on line 232 in pkg/connector/client/percipio.go

View workflow job for this annotation

GitHub Actions / test

cannot use target (variable of type Report) as *Report value in argument to c.Cache.Load

Check failure on line 232 in pkg/connector/client/percipio.go

View workflow job for this annotation

GitHub Actions / go-test (1.22.x, ubuntu-latest)

cannot use target (variable of type Report) as *Report value in argument to c.Cache.Load

Check failure on line 232 in pkg/connector/client/percipio.go

View workflow job for this annotation

GitHub Actions / go-lint

cannot use target (variable of type Report) as *Report value in argument to c.Cache.Load (typecheck)

Check failure on line 232 in pkg/connector/client/percipio.go

View workflow job for this annotation

GitHub Actions / go-lint

cannot use target (variable of type Report) as *Report value in argument to c.Cache.Load) (typecheck)

Check failure on line 232 in pkg/connector/client/percipio.go

View workflow job for this annotation

GitHub Actions / go-lint

cannot use target (variable of type Report) as *Report value in argument to c.Cache.Load) (typecheck)
if err != nil {
return nil, err
}
return ratelimitData, nil
}
49 changes: 49 additions & 0 deletions pkg/connector/client/reportCache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package client

type StatusesStore map[string]map[string]string

// Load given a Report (which again, can be on the order of 1 GB), and just
// create a mapping of course IDs to a mapping of user IDs to statuses. e.g.:
//
// {
// "00000000-0000-0000-0000-000000000000": {
// "00000000-0000-0000-0000-000000000001": "in_progress",
// "00000000-0000-0000-0000-000000000002": "completed",
// },
// }
func (r StatusesStore) Load(report *Report) error {
for _, row := range *report {
found, ok := r[row.ContentUUID]
if !ok {
found = make(map[string]string)
}

found[row.UserUUID] = toStatus(row.Status)
r[row.ContentUUID] = found
}

return nil
}

// Get - return a mapping of user IDs to course completion status.
// TODO(marcos) Should we use enums instead?
func (r StatusesStore) Get(courseId string) map[string]string {
found, ok := r[courseId]
if !ok {
// `nil` and empty map are equivalent.
return nil
}
return found
}

// toStatus convert Percipio status to C1 status.
func toStatus(status string) string {
switch status {
case "Started":
return "in_progress"
case "Completed":
return "completed"
default:
return "unknown"
}
}
20 changes: 20 additions & 0 deletions pkg/connector/client/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,26 @@ func (c *Client) get(
)
}

func (c *Client) post(
ctx context.Context,
path string,
body interface{},
target interface{},
) (
*http.Response,
*v2.RateLimitDescription,
error,
) {
return c.doRequest(
ctx,
http.MethodPost,
path,
nil,
body,
&target,
)
}

func (c *Client) doRequest(
ctx context.Context,
method string,
Expand Down
41 changes: 38 additions & 3 deletions pkg/connector/courses.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/conductorone/baton-sdk/pkg/annotations"
"github.com/conductorone/baton-sdk/pkg/pagination"
"github.com/conductorone/baton-sdk/pkg/types/entitlement"
"github.com/conductorone/baton-sdk/pkg/types/grant"
resourceSdk "github.com/conductorone/baton-sdk/pkg/types/resource"
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
"go.uber.org/zap"
Expand Down Expand Up @@ -122,18 +123,52 @@ func (o *courseBuilder) Entitlements(
}, "", nil, nil
}

// Grants always returns an empty slice for users since they don't have any entitlements.
// Grants we have to do a pretty complicated set of maneuvers here to fetch
// grants. First, we need to POST a request to the "generate report" endpoint,
// which returns a UUID that we can use to interpolate a URL where the report
// will appear. From there we have to _poll_ that endpoint until it states that
// the report is ready. Finally, we need to store the data (which can be on the
// order of 1 GB) in memory so that we can find grants for a given resource.
func (o *courseBuilder) Grants(
ctx context.Context,
resource *v2.Resource,
pToken *pagination.Token,
_ *pagination.Token,
) (
[]*v2.Grant,
string,
annotations.Annotations,
error,
) {
return nil, "", nil, nil
var outputAnnotations annotations.Annotations
if o.client.ReportStatus.Status == "" {
ratelimitData, err := o.client.GenerateLearningActivityReport(ctx)
outputAnnotations.WithRateLimiting(ratelimitData)
if err != nil {
return nil, "", outputAnnotations, err
}
}

if o.client.ReportStatus.Status == "pending" {
ratelimitData, err := o.client.GetLearningActivityReport(ctx)
outputAnnotations.WithRateLimiting(ratelimitData)
if err != nil {
return nil, "", outputAnnotations, err
}
}

statusesMap := o.client.Cache.Get(resource.Id.Resource)

grants := make([]*v2.Grant, 0)
for userId, status := range statusesMap {
principalId, err := resourceSdk.NewResourceID(userResourceType, userId)
if err != nil {
return nil, "", outputAnnotations, err
}
nextGrant := grant.NewGrant(resource, status, principalId)
grants = append(grants, nextGrant)
}

return grants, "", outputAnnotations, nil
}

func newCourseBuilder(client *client.Client) *courseBuilder {
Expand Down
Loading

0 comments on commit b877d09

Please sign in to comment.