Skip to content
Draft
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
69 changes: 69 additions & 0 deletions admin/auth_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,56 @@ func (s *Service) IssueMagicAuthToken(ctx context.Context, opts *IssueMagicAuthT
return &magicAuthToken{model: dat, token: tkn}, nil
}

type embedAuthToken struct {
model *database.EmbedAuthToken
token *authtoken.Token
}

func (t *embedAuthToken) Token() *authtoken.Token {
return t.token
}

func (t *embedAuthToken) TokenModel() any {
return t.model
}

func (t *embedAuthToken) OwnerID() string {
return fmt.Sprintf("%s::%s", t.model.ProjectID, t.model.Email)
}

// IssueEmbedAuthToken issues an embed auth token for an email in a project.
// The openURL is optional; if set, it's used as the base URL for open links in emails from reports and alerts created with the token.
// If resources is non-empty, reports created with the token are limited to those resources.
func (s *Service) IssueEmbedAuthToken(ctx context.Context, projectID, email, openURL string, resources []database.ResourceName, ttl *time.Duration) (AuthToken, error) {
tkn := authtoken.NewRandom(authtoken.TypeEmbed)

var expiresOn *time.Time
if ttl != nil {
t := time.Now().Add(*ttl)
expiresOn = &t
}

var openURLPtr *string
if openURL != "" {
openURLPtr = &openURL
}

eat, err := s.DB.InsertEmbedAuthToken(ctx, &database.InsertEmbedAuthTokenOptions{
ID: tkn.ID.String(),
SecretHash: tkn.SecretHash(),
ProjectID: projectID,
Email: email,
OpenURL: openURLPtr,
Resources: resources,
ExpiresOn: expiresOn,
})
if err != nil {
return nil, err
}

return &embedAuthToken{model: eat, token: tkn}, nil
}

// ExtendBrowserSessionAuthToken extends a Rill web browser session token when its
// remaining lifetime is at or below refreshThreshold.
func (s *Service) ExtendBrowserSessionAuthToken(ctx context.Context, authTok AuthToken, fullTTL, refreshThreshold time.Duration) error {
Expand Down Expand Up @@ -261,6 +311,8 @@ func (s *Service) RevokeAuthToken(ctx context.Context, token string) error {
return fmt.Errorf("deployment auth tokens cannot be revoked")
case authtoken.TypeMagic:
return s.DB.DeleteMagicAuthToken(ctx, parsed.ID.String())
case authtoken.TypeEmbed:
return s.DB.DeleteEmbedAuthToken(ctx, parsed.ID.String())
default:
return fmt.Errorf("unknown auth token type %q", parsed.Type)
}
Expand Down Expand Up @@ -391,6 +443,23 @@ func (s *Service) validateAuthTokenUncached(ctx context.Context, token string) (
s.Used.MagicAuthToken(mat.ID)

return &magicAuthToken{model: mat, token: parsed}, nil
case authtoken.TypeEmbed:
eat, err := s.DB.FindEmbedAuthToken(ctx, parsed.ID.String())
if err != nil {
return nil, err
}

if eat.ExpiresOn != nil && eat.ExpiresOn.Before(time.Now()) {
return nil, fmt.Errorf("auth token is expired")
}

if !bytes.Equal(eat.SecretHash, parsed.SecretHash()) {
return nil, fmt.Errorf("invalid auth token")
}

s.Used.EmbedAuthToken(eat.ID)

return &embedAuthToken{model: eat, token: parsed}, nil
default:
return nil, fmt.Errorf("unknown auth token type %q", parsed.Type)
}
Expand Down
32 changes: 32 additions & 0 deletions admin/database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,14 @@ type DB interface {
DeleteMagicAuthTokens(ctx context.Context, ids []string) error
DeleteExpiredMagicAuthTokens(ctx context.Context, retention time.Duration) error

FindEmbedAuthTokens(ctx context.Context, projectID, email string) ([]*EmbedAuthToken, error)
FindEmbedAuthToken(ctx context.Context, id string) (*EmbedAuthToken, error)
InsertEmbedAuthToken(ctx context.Context, opts *InsertEmbedAuthTokenOptions) (*EmbedAuthToken, error)
UpdateEmbedAuthTokenUsedOn(ctx context.Context, ids []string) error
DeleteEmbedAuthToken(ctx context.Context, id string) error
DeleteExpiredEmbedAuthTokens(ctx context.Context, retention time.Duration) error
DeleteInactiveEmbedAuthTokens(ctx context.Context, retention time.Duration) error

FindNotificationTokens(ctx context.Context, resourceKind, resourceName string) ([]*NotificationToken, error)
FindNotificationTokensWithSecret(ctx context.Context, resourceKind, resourceName string) ([]*NotificationTokenWithSecret, error)
FindNotificationTokenForMagicAuthToken(ctx context.Context, magicAuthTokenID string) (*NotificationToken, error)
Expand Down Expand Up @@ -900,6 +908,30 @@ type InsertMagicAuthTokenOptions struct {
Internal bool
}

// EmbedAuthToken is a persistent API token for an embedded project, scoped to an email.
type EmbedAuthToken struct {
ID string
SecretHash []byte `db:"secret_hash"`
ProjectID string `db:"project_id"`
Email string `db:"email"`
OpenURL *string `db:"open_url"` // Base URL for open links in emails from reports and alerts created with the token
Resources []ResourceName `db:"resources"` // If non-empty, limits reports created with the token to these resources
CreatedOn time.Time `db:"created_on"`
ExpiresOn *time.Time `db:"expires_on"`
UsedOn time.Time `db:"used_on"`
}

// InsertEmbedAuthTokenOptions defines options for creating an EmbedAuthToken.
type InsertEmbedAuthTokenOptions struct {
ID string
SecretHash []byte
ProjectID string `validate:"required"`
Email string `validate:"required"`
OpenURL *string
Resources []ResourceName
ExpiresOn *time.Time
}

type NotificationToken struct {
ID string
ResourceKind string `db:"resource_kind"`
Expand Down
13 changes: 13 additions & 0 deletions admin/database/postgres/migrations/0099.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
CREATE TABLE embed_auth_tokens (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
secret_hash BYTEA NOT NULL,
project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
email TEXT NOT NULL,
open_url TEXT,
resources JSONB NOT NULL DEFAULT '[]'::jsonb,
created_on TIMESTAMPTZ DEFAULT now() NOT NULL,
expires_on TIMESTAMPTZ,
used_on TIMESTAMPTZ DEFAULT now() NOT NULL
);

CREATE INDEX embed_auth_tokens_project_email_idx ON embed_auth_tokens (project_id, email);
90 changes: 90 additions & 0 deletions admin/database/postgres/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -1740,6 +1740,82 @@ func (c *connection) DeleteExpiredMagicAuthTokens(ctx context.Context, retention
return parseErr("magic auth token", err)
}

// FindEmbedAuthTokens returns the embed auth tokens for an email in a project.
func (c *connection) FindEmbedAuthTokens(ctx context.Context, projectID, email string) ([]*database.EmbedAuthToken, error) {
var dtos []*embedAuthTokenDTO
err := c.getDB(ctx).SelectContext(ctx, &dtos, "SELECT t.* FROM embed_auth_tokens t WHERE t.project_id=$1 AND t.email=$2", projectID, email)
if err != nil {
return nil, parseErr("embed auth tokens", err)
}
res := make([]*database.EmbedAuthToken, len(dtos))
for i, dto := range dtos {
res[i], err = dto.embedAuthTokenFromDTO()
if err != nil {
return nil, err
}
}
return res, nil
}

// FindEmbedAuthToken returns an embed auth token.
func (c *connection) FindEmbedAuthToken(ctx context.Context, id string) (*database.EmbedAuthToken, error) {
res := &embedAuthTokenDTO{}
err := c.getDB(ctx).QueryRowxContext(ctx, "SELECT t.* FROM embed_auth_tokens t WHERE t.id=$1", id).StructScan(res)
if err != nil {
return nil, parseErr("embed auth token", err)
}
return res.embedAuthTokenFromDTO()
}

// InsertEmbedAuthToken inserts an embed auth token.
func (c *connection) InsertEmbedAuthToken(ctx context.Context, opts *database.InsertEmbedAuthTokenOptions) (*database.EmbedAuthToken, error) {
if err := database.Validate(opts); err != nil {
return nil, err
}

resources, err := marshalResourceNames(opts.Resources)
if err != nil {
return nil, err
}

res := &embedAuthTokenDTO{}
err = c.getDB(ctx).QueryRowxContext(ctx, `
INSERT INTO embed_auth_tokens (id, secret_hash, project_id, email, open_url, resources, expires_on)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
opts.ID, opts.SecretHash, opts.ProjectID, opts.Email, opts.OpenURL, resources, opts.ExpiresOn,
).StructScan(res)
if err != nil {
return nil, parseErr("embed auth token", err)
}
return res.embedAuthTokenFromDTO()
}

func (c *connection) UpdateEmbedAuthTokenUsedOn(ctx context.Context, ids []string) error {
_, err := c.getDB(ctx).ExecContext(ctx, "UPDATE embed_auth_tokens SET used_on=now() WHERE id=ANY($1)", ids)
if err != nil {
return parseErr("embed auth token", err)
}
return nil
}

// DeleteEmbedAuthToken deletes an embed auth token.
func (c *connection) DeleteEmbedAuthToken(ctx context.Context, id string) error {
res, err := c.getDB(ctx).ExecContext(ctx, "DELETE FROM embed_auth_tokens WHERE id=$1", id)
return checkDeleteRow("embed auth token", res, err)
}

// DeleteExpiredEmbedAuthTokens deletes expired embed auth tokens.
func (c *connection) DeleteExpiredEmbedAuthTokens(ctx context.Context, retention time.Duration) error {
_, err := c.getDB(ctx).ExecContext(ctx, "DELETE FROM embed_auth_tokens WHERE expires_on IS NOT NULL AND expires_on + $1 < now()", retention)
return parseErr("embed auth token", err)
}

// DeleteInactiveEmbedAuthTokens deletes embed auth tokens that have not been used within the specified retention period.
func (c *connection) DeleteInactiveEmbedAuthTokens(ctx context.Context, retention time.Duration) error {
_, err := c.getDB(ctx).ExecContext(ctx, "DELETE FROM embed_auth_tokens WHERE used_on + $1 < now() AND created_on + $1 < now()", retention)
return parseErr("embed auth token", err)
}

func (c *connection) FindNotificationTokens(ctx context.Context, resourceKind, resourceName string) ([]*database.NotificationToken, error) {
var res []*database.NotificationToken
err := c.getDB(ctx).SelectContext(ctx, &res, `SELECT * FROM notification_tokens WHERE resource_kind=$1 AND resource_name=$2`, resourceKind, resourceName)
Expand Down Expand Up @@ -3653,6 +3729,20 @@ func (c *connection) magicAuthTokenFromDTO(dto *magicAuthTokenDTO, fetchSecret b
return dto.MagicAuthToken, nil
}

// embedAuthTokenDTO wraps database.EmbedAuthToken, using the pgtype package to handle types that pgx can't read directly into their native Go types.
type embedAuthTokenDTO struct {
*database.EmbedAuthToken
Resources pgtype.JSONB `db:"resources"`
}

func (dto *embedAuthTokenDTO) embedAuthTokenFromDTO() (*database.EmbedAuthToken, error) {
err := dto.Resources.AssignTo(&dto.EmbedAuthToken.Resources)
if err != nil {
return nil, err
}
return dto.EmbedAuthToken, nil
}

// magicAuthTokenWithUserDTO wraps database.MagicAuthTokenWithUser, using the pgtype package to handly types that pgx can't read directly into their native Go types.
type magicAuthTokenWithUserDTO struct {
*database.MagicAuthTokenWithUser
Expand Down
4 changes: 4 additions & 0 deletions admin/jobs/river/delete_expired_tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,9 @@ func (w *DeleteExpiredTokensWorker) Work(ctx context.Context, job *river.Job[Del
if err != nil {
return err
}
err = w.admin.DB.DeleteExpiredEmbedAuthTokens(ctx, retention)
if err != nil {
return err
}
return nil
}
4 changes: 4 additions & 0 deletions admin/jobs/river/river.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ func New(ctx context.Context, dsn string, adm *admin.Service) (jobs.Client, erro
// token cleanup workers
river.AddWorker(workers, &DeleteUnusedUserTokenWorker{admin: adm, logger: adm.Logger})
river.AddWorker(workers, &DeleteUnusedServiceTokenWorker{admin: adm, logger: adm.Logger})
river.AddWorker(workers, &DeleteUnusedEmbedTokenWorker{admin: adm, logger: adm.Logger})

river.AddWorker(workers, &CheckProvisionersWorker{admin: adm, logger: adm.Logger})
river.AddWorker(workers, &BillingReporterWorker{admin: adm, logger: billingLogger})
Expand All @@ -130,6 +131,7 @@ func New(ctx context.Context, dsn string, adm *admin.Service) (jobs.Client, erro
{&SubscriptionCancellationCheckArgs{}, "20 1 * * *", true}, // daily at 1:20am UTC
{&DeleteUnusedUserTokenArgs{}, "0 */12 * * *", true}, // every 12 hours
{&DeleteUnusedServiceTokenArgs{}, "0 */12 * * *", true}, // every 12 hours
{&DeleteUnusedEmbedTokenArgs{}, "0 */12 * * *", true}, // every 12 hours
{&deleteUnusedGithubReposArgs{}, "0 */6 * * *", true}, // every 6 hours
{&HibernateInactiveOrgsArgs{}, "0 7 * * 1", true}, // Monday at 7:00am UTC
{&CheckProvisionersArgs{}, "*/15 * * * *", true}, // every 15 minutes
Expand Down Expand Up @@ -319,6 +321,8 @@ func (c *Client) EnqueueByKind(ctx context.Context, kind string) (*jobs.InsertRe
jobArgs = DeleteUnusedUserTokenArgs{}
case "delete_unused_service_tokens":
jobArgs = DeleteUnusedServiceTokenArgs{}
case "delete_unused_embed_tokens":
jobArgs = DeleteUnusedEmbedTokenArgs{}
case "delete_unused_github_repos":
jobArgs = deleteUnusedGithubReposArgs{}
default:
Expand Down
21 changes: 21 additions & 0 deletions admin/jobs/river/token_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,24 @@ func (w *DeleteUnusedServiceTokenWorker) Work(ctx context.Context, job *river.Jo
}
return nil
}

// DeleteUnusedEmbedTokenArgs and Worker for embed tokens only

type DeleteUnusedEmbedTokenArgs struct{}

func (DeleteUnusedEmbedTokenArgs) Kind() string { return "delete_unused_embed_tokens" }

type DeleteUnusedEmbedTokenWorker struct {
river.WorkerDefaults[DeleteUnusedEmbedTokenArgs]
admin *admin.Service
logger *zap.Logger
}

// NewDeleteUnusedEmbedTokenWorker creates a new worker that deletes unused embed tokens.
func (w *DeleteUnusedEmbedTokenWorker) Work(ctx context.Context, job *river.Job[DeleteUnusedEmbedTokenArgs]) error {
err := w.admin.DB.DeleteInactiveEmbedAuthTokens(ctx, tokenRetentionPeriod)
if err != nil {
return fmt.Errorf("failed to delete unused embed tokens: %w", err)
}
return nil
}
34 changes: 34 additions & 0 deletions admin/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,40 @@ func (s *Service) ProjectPermissionsForMagicAuthToken(ctx context.Context, proje
}, nil
}

func (s *Service) ProjectPermissionsForEmbedAuthToken(ctx context.Context, projectID string, tkn *database.EmbedAuthToken) (*adminv1.ProjectPermissions, error) {
// No access if the token belongs to another project
if projectID != tkn.ProjectID {
return &adminv1.ProjectPermissions{}, nil
}

// Grant basic read access to the project and its prod deployment.
// Embed users can create reports, but can only manage the ones they own (enforced by the handlers).
return &adminv1.ProjectPermissions{
Admin: false,
ReadProject: true,
ManageProject: false,
ReadProd: true,
ReadProdStatus: false,
ManageProd: false,
ReadDev: false,
ReadDevStatus: false,
ManageDev: false,
ReadProvisionerResources: false,
ManageProvisionerResources: false,
ReadProjectMembers: false,
ManageProjectMembers: false,
ManageProjectAdmins: false,
CreateMagicAuthTokens: false,
ManageMagicAuthTokens: false,
CreateReports: true,
ManageReports: false,
CreateAlerts: false,
ManageAlerts: false,
CreateBookmarks: false,
ManageBookmarks: false,
}, nil
}

// UnionOrgRoles merges an organization role's permissions into the given permissions object.
func UnionOrgRoles(a *adminv1.OrganizationPermissions, b *database.OrganizationRole) *adminv1.OrganizationPermissions {
return &adminv1.OrganizationPermissions{
Expand Down
3 changes: 2 additions & 1 deletion admin/pkg/authtoken/authtoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@ const (
TypeService Type = "svc"
TypeDeployment Type = "dpl"
TypeMagic Type = "mgc"
TypeEmbed Type = "emb"
)

// Validate checks that the type is a known enum value.
func (t Type) Validate() bool {
switch t {
case TypeUser, TypeService, TypeDeployment, TypeMagic:
case TypeUser, TypeService, TypeDeployment, TypeMagic, TypeEmbed:
return true
default:
return false
Expand Down
Loading
Loading