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
59 changes: 59 additions & 0 deletions admin/auth_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,46 @@ 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)
}

func (s *Service) IssueEmbedAuthToken(ctx context.Context, projectID, email string, ttl *time.Duration) (AuthToken, error) {
tkn := authtoken.NewRandom(authtoken.TypeEmbed)

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

eat, err := s.DB.InsertEmbedAuthToken(ctx, &database.InsertEmbedAuthTokenOptions{
ID: tkn.ID.String(),
SecretHash: tkn.SecretHash(),
ProjectID: projectID,
Email: email,
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 +301,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 +433,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")
}

// TODO: used

return &embedAuthToken{model: eat, token: parsed}, err
default:
return nil, fmt.Errorf("unknown auth token type %q", parsed.Type)
}
Expand Down
28 changes: 28 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 @@ -880,6 +888,26 @@ 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"`
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"`
ExpiresOn *time.Time
}

type NotificationToken struct {
ID string
ResourceKind string `db:"resource_kind"`
Expand Down
11 changes: 11 additions & 0 deletions admin/database/postgres/migrations/0098.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
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,
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);
64 changes: 64 additions & 0 deletions admin/database/postgres/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -1740,6 +1740,70 @@ 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 res []*database.EmbedAuthToken
err := c.getDB(ctx).SelectContext(ctx, &res, "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)
}
return res, nil
}

// FindEmbedAuthToken returns an embed auth token.
func (c *connection) FindEmbedAuthToken(ctx context.Context, id string) (*database.EmbedAuthToken, error) {
res := &database.EmbedAuthToken{}
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, nil
}

// 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
}

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

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
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
}
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 and alerts, 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: true,
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