Skip to content
Open
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
194 changes: 143 additions & 51 deletions admin/server/reports.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"path"
"regexp"
Expand All @@ -22,13 +23,15 @@ import (
"golang.org/x/exp/slices"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
"gopkg.in/yaml.v3"
)

func (s *Server) GetReportMeta(ctx context.Context, req *adminv1.GetReportMetaRequest) (*adminv1.GetReportMetaResponse, error) {
observability.AddRequestAttributes(ctx,
attribute.String("args.project_id", req.ProjectId),
attribute.String("args.report", req.Report),
attribute.String("args.resolver", req.Resolver),
attribute.StringSlice("args.email_recipients", req.EmailRecipients),
attribute.String("args.execution_time", req.ExecutionTime.String()),
attribute.Bool("args.anon_recipients", req.AnonRecipients),
Expand Down Expand Up @@ -56,7 +59,7 @@ func (s *Server) GetReportMeta(ctx context.Context, req *adminv1.GetReportMetaRe
return nil, status.Error(codes.InvalidArgument, err.Error())
}

urls := make(map[string]*adminv1.GetReportMetaResponse_URLs)
delivery := make(map[string]*adminv1.GetReportMetaResponse_DeliveryMeta)

var recipients []string
recipients = append(recipients, req.EmailRecipients...)
Expand All @@ -75,15 +78,77 @@ func (s *Server) GetReportMeta(ctx context.Context, req *adminv1.GetReportMetaRe
}

var tokens map[string]string
if webOpenMode == WebOpenModeRecipient {
tokens, err = s.createUnsubMagicTokens(ctx, proj.ID, req.Report, req.OwnerId, ownerEmail, recipients)
if webOpenMode == WebOpenModeRecipient || req.Resolver == "ai" {
// in recipient mode tokens are used for unsubscribing and for ai reports, shared sessions are created so token is just used for authentication, so no access to resources is needed
tokens, err = s.createMagicTokensWithoutResources(ctx, proj.ID, req.Report, req.OwnerId, recipients)
} else {
tokens, err = s.createMagicTokens(ctx, proj.OrganizationID, proj.ID, req.Report, req.OwnerId, recipients, req.Resources)
}
if err != nil {
return nil, fmt.Errorf("failed to issue magic auth tokens: %w", err)
}

recipientUserIDs := make(map[string]string)
recipientUserAttrs := make(map[string]*structpb.Struct)
if req.Resolver == "ai" {
if webOpenMode == WebOpenModeCreator {
// in creator mode, only look up owner and use their attributes for all recipients
if req.OwnerId != "" {
attr, _, err := s.getAttributesForUser(ctx, proj.OrganizationID, proj.ID, req.OwnerId, "")
if err != nil {
return nil, err
}
pbAttrs, err := structpb.NewStruct(attr)
if err != nil {
return nil, err
}
for _, recipient := range recipients {
recipientUserAttrs[recipient] = pbAttrs
recipientUserIDs[recipient] = req.OwnerId
}
}
} else {
// Build a map of email -> user ID for recipients
for _, recipient := range recipients {
if recipient == "" {
continue
}
userID := ""
if recipient == ownerEmail {
userID = req.OwnerId
} else {
// Look up user by email
user, err := s.admin.DB.FindUserByEmail(ctx, recipient)
if err != nil && !errors.Is(err, database.ErrNotFound) {
return nil, err
}
if user != nil {
userID = user.ID
}
}
// If user not found, leave empty - they may not be a Rill user (which should not happen in recipient mode); we will just skip running the report for them
if userID != "" {
attr, _, err := s.getAttributesForUser(ctx, proj.OrganizationID, proj.ID, userID, "")
if err != nil {
return nil, err
}
member := false
if val, ok := attr["member"]; ok {
member, _ = val.(bool)
}
if !member {
continue
}
recipientUserAttrs[recipient], err = structpb.NewStruct(attr)
recipientUserIDs[recipient] = userID
if err != nil {
return nil, err
}
}
}
}
}

// Generate URLs for each recipient based on web open mode, and whether they are the owner -
// Owner does not get a token in recipient mode and does not get an unsubscribe link.
// Recipients in creator mode get a token and an unsubscribe link.
Expand All @@ -94,43 +159,53 @@ func (s *Server) GetReportMeta(ctx context.Context, req *adminv1.GetReportMetaRe
if recipient == ownerEmail {
if webOpenMode == WebOpenModeRecipient {
// owner in recipient mode gets plain open and export url without token as token does not have any access
urls[recipient] = &adminv1.GetReportMetaResponse_URLs{
OpenUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportOpen(org.Name, proj.Name, req.Report, "", req.ExecutionTime.AsTime()),
delivery[recipient] = &adminv1.GetReportMetaResponse_DeliveryMeta{
OpenUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportOpen(org.Name, proj.Name, req.Report, req.Resolver, "", req.ExecutionTime.AsTime()),
ExportUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportExport(org.Name, proj.Name, req.Report, ""),
EditUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportEdit(org.Name, proj.Name, req.Report),
UserId: recipientUserIDs[recipient],
UserAttrs: recipientUserAttrs[recipient],
}
} else {
urls[recipient] = &adminv1.GetReportMetaResponse_URLs{
OpenUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportOpen(org.Name, proj.Name, req.Report, tokens[recipient], req.ExecutionTime.AsTime()),
delivery[recipient] = &adminv1.GetReportMetaResponse_DeliveryMeta{
OpenUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportOpen(org.Name, proj.Name, req.Report, req.Resolver, tokens[recipient], req.ExecutionTime.AsTime()),
ExportUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportExport(org.Name, proj.Name, req.Report, tokens[recipient]),
EditUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportEdit(org.Name, proj.Name, req.Report),
UserId: recipientUserIDs[recipient],
UserAttrs: recipientUserAttrs[recipient],
}
}
continue
}
if webOpenMode == WebOpenModeCreator {
urls[recipient] = &adminv1.GetReportMetaResponse_URLs{
OpenUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportOpen(org.Name, proj.Name, req.Report, tokens[recipient], req.ExecutionTime.AsTime()),
delivery[recipient] = &adminv1.GetReportMetaResponse_DeliveryMeta{
OpenUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportOpen(org.Name, proj.Name, req.Report, req.Resolver, tokens[recipient], req.ExecutionTime.AsTime()),
ExportUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportExport(org.Name, proj.Name, req.Report, tokens[recipient]),
UnsubscribeUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportUnsubscribe(org.Name, proj.Name, req.Report, tokens[recipient], recipient),
UserId: recipientUserIDs[recipient],
UserAttrs: recipientUserAttrs[recipient],
}
} else if webOpenMode == WebOpenModeRecipient {
urls[recipient] = &adminv1.GetReportMetaResponse_URLs{
OpenUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportOpen(org.Name, proj.Name, req.Report, "", req.ExecutionTime.AsTime()),
delivery[recipient] = &adminv1.GetReportMetaResponse_DeliveryMeta{
OpenUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportOpen(org.Name, proj.Name, req.Report, req.Resolver, "", req.ExecutionTime.AsTime()),
ExportUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportExport(org.Name, proj.Name, req.Report, ""),
UnsubscribeUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportUnsubscribe(org.Name, proj.Name, req.Report, tokens[recipient], recipient), // still use token for unsubscribe so that it works seamlessly for non Rill users
UserId: recipientUserIDs[recipient],
UserAttrs: recipientUserAttrs[recipient],
}
} else {
// same as recipient but no open url
urls[recipient] = &adminv1.GetReportMetaResponse_URLs{
delivery[recipient] = &adminv1.GetReportMetaResponse_DeliveryMeta{
ExportUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportExport(org.Name, proj.Name, req.Report, ""),
UnsubscribeUrl: s.admin.URLs.WithCustomDomain(org.CustomDomain).ReportUnsubscribe(org.Name, proj.Name, req.Report, tokens[recipient], recipient), // still use token for unsubscribe so that it works seamlessly for non Rill users
UserId: recipientUserIDs[recipient],
UserAttrs: recipientUserAttrs[recipient],
}
}
}

return &adminv1.GetReportMetaResponse{
RecipientUrls: urls,
DeliveryMeta: delivery,
}, nil
}

Expand Down Expand Up @@ -515,11 +590,21 @@ func (s *Server) yamlForManagedReport(opts *adminv1.ReportOptions, ownerUserID s
res.Refresh.TimeZone = opts.RefreshTimeZone
res.Watermark = "inherit"
res.Intervals.Duration = opts.IntervalDuration
res.Query.Name = opts.QueryName
res.Query.ArgsJSON = opts.QueryArgsJson
res.Export.Format = opts.ExportFormat.String()
res.Export.IncludeHeader = opts.ExportIncludeHeader
res.Export.Limit = uint(opts.ExportLimit)

// Handle resolver-based reports (new style) vs legacy query-based reports
if opts.Resolver != "" && opts.ResolverProperties != nil {
res.Data = map[string]any{
opts.Resolver: opts.ResolverProperties.AsMap(),
}
} else {
// Legacy query-based report
res.Query.Name = opts.QueryName
res.Query.ArgsJSON = opts.QueryArgsJson
res.Export.Format = opts.ExportFormat.String()
res.Export.IncludeHeader = opts.ExportIncludeHeader
res.Export.Limit = uint(opts.ExportLimit)
}

res.Notify.Email.Recipients = opts.EmailRecipients
res.Notify.Slack.Channels = opts.SlackChannels
res.Notify.Slack.Users = opts.SlackUsers
Expand All @@ -545,40 +630,50 @@ func (s *Server) yamlForManagedReport(opts *adminv1.ReportOptions, ownerUserID s
}

func (s *Server) yamlForCommittedReport(opts *adminv1.ReportOptions) ([]byte, error) {
// Format args as pretty YAML
var args map[string]interface{}
if opts.QueryArgsJson != "" {
err := json.Unmarshal([]byte(opts.QueryArgsJson), &args)
if err != nil {
return nil, fmt.Errorf("failed to parse queryArgsJSON: %w", err)
}
}

// Format export format as pretty string
var exportFormat string
switch opts.ExportFormat {
case runtimev1.ExportFormat_EXPORT_FORMAT_CSV:
exportFormat = "csv"
case runtimev1.ExportFormat_EXPORT_FORMAT_PARQUET:
exportFormat = "parquet"
case runtimev1.ExportFormat_EXPORT_FORMAT_XLSX:
exportFormat = "xlsx"
default:
exportFormat = opts.ExportFormat.String()
}

res := reportYAML{}
res.Type = "report"
res.DisplayName = opts.DisplayName
res.Refresh.Cron = opts.RefreshCron
res.Refresh.TimeZone = opts.RefreshTimeZone
res.Watermark = "inherit"
res.Intervals.Duration = opts.IntervalDuration
res.Query.Name = opts.QueryName
res.Query.Args = args
res.Export.Format = exportFormat
res.Export.IncludeHeader = opts.ExportIncludeHeader
res.Export.Limit = uint(opts.ExportLimit)

// Handle resolver-based reports (new style) vs legacy query-based reports
if opts.Resolver != "" && opts.ResolverProperties != nil {
res.Data = map[string]any{
opts.Resolver: opts.ResolverProperties.AsMap(),
}
} else {
// Legacy query-based report
// Format args as pretty YAML
var args map[string]interface{}
if opts.QueryArgsJson != "" {
err := json.Unmarshal([]byte(opts.QueryArgsJson), &args)
if err != nil {
return nil, fmt.Errorf("failed to parse queryArgsJSON: %w", err)
}
}

// Format export format as pretty string
var exportFormat string
switch opts.ExportFormat {
case runtimev1.ExportFormat_EXPORT_FORMAT_CSV:
exportFormat = "csv"
case runtimev1.ExportFormat_EXPORT_FORMAT_PARQUET:
exportFormat = "parquet"
case runtimev1.ExportFormat_EXPORT_FORMAT_XLSX:
exportFormat = "xlsx"
default:
exportFormat = opts.ExportFormat.String()
}

res.Query.Name = opts.QueryName
res.Query.Args = args
res.Export.Format = exportFormat
res.Export.IncludeHeader = opts.ExportIncludeHeader
res.Export.Limit = uint(opts.ExportLimit)
}

res.Notify.Email.Recipients = opts.EmailRecipients
res.Notify.Slack.Channels = opts.SlackChannels
res.Notify.Slack.Users = opts.SlackUsers
Expand Down Expand Up @@ -707,7 +802,7 @@ func (s *Server) createMagicTokens(ctx context.Context, orgID, projectID, report
return emailTokens, nil
}

func (s *Server) createUnsubMagicTokens(ctx context.Context, projectID, reportName, ownerID, ownerEmail string, emails []string) (map[string]string, error) {
func (s *Server) createMagicTokensWithoutResources(ctx context.Context, projectID, reportName, ownerID string, emails []string) (map[string]string, error) {
var createdByUserID *string
if ownerID != "" {
createdByUserID = &ownerID
Expand All @@ -729,10 +824,6 @@ func (s *Server) createUnsubMagicTokens(ctx context.Context, projectID, reportNa

emailTokens := make(map[string]string)
for _, email := range emails {
if ownerEmail != "" && strings.EqualFold(ownerEmail, email) {
// skip creating unsubscribe token for owner email
continue
}
// set user attrs as per the email
mgcOpts.Attributes = map[string]interface{}{
"name": "",
Expand Down Expand Up @@ -798,7 +889,8 @@ type reportYAML struct {
Intervals struct {
Duration string `yaml:"duration"`
} `yaml:"intervals"`
Query struct {
Data map[string]any `yaml:"data,omitempty"` // Generic data resolver block (e.g., data.ai, data.sql)
Query struct { // Legacy query-based report (deprecated - use data instead)
Name string `yaml:"name"`
Args map[string]any `yaml:"args,omitempty"`
ArgsJSON string `yaml:"args_json,omitempty"`
Expand Down
1 change: 1 addition & 0 deletions admin/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ func (s *Server) jwtAttributesForUser(ctx context.Context, userID, orgID string,
"domain": user.Email[strings.LastIndex(user.Email, "@")+1:],
"groups": groupNames,
"admin": projectPermissions.ManageProject,
"member": projectPermissions.ReadProd,
}

for k, v := range attributes {
Expand Down
8 changes: 7 additions & 1 deletion admin/urls.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,13 @@ func (u *URLs) DenyProjectAccess(org, project, id string) string {
}

// ReportOpen returns the URL for opening a report in the frontend.
func (u *URLs) ReportOpen(org, project, report, token string, executionTime time.Time) string {
func (u *URLs) ReportOpen(org, project, report, resolver, token string, executionTime time.Time) string {
if resolver == "ai" {
if token == "" {
return urlutil.MustJoinURL(u.Frontend(), org, project, "-", "ai", "{session_id}") // {session_id} will be replaced with actual session id on runtime after ai session is created
}
return urlutil.MustWithQuery(urlutil.MustJoinURL(u.Frontend(), org, project, "-", "ai", "{session_id}"), map[string]string{"token": token})
}
if token == "" {
return urlutil.MustWithQuery(urlutil.MustJoinURL(u.Frontend(), org, project, "-", "reports", report, "open"), map[string]string{"execution_time": executionTime.UTC().Format(time.RFC3339)})
}
Expand Down
2 changes: 1 addition & 1 deletion cli/pkg/local/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func (l *localAdminService) GetDeploymentConfig(ctx context.Context) (*drivers.D
}

// GetReportMetadata implements drivers.AdminService.
func (l *localAdminService) GetReportMetadata(ctx context.Context, reportName, ownerID, webOpenMode string, emailRecipients []string, anonRecipients bool, executionTime time.Time) (*drivers.ReportMetadata, error) {
func (l *localAdminService) GetReportMetadata(ctx context.Context, reportName, resolver, ownerID, webOpenMode string, emailRecipients []string, anonRecipients bool, executionTime time.Time) (*drivers.ReportMetadata, error) {
return nil, drivers.ErrNotImplemented
}

Expand Down
Loading
Loading