Skip to content

Commit

Permalink
feat: Update user info every 24h and adds command with feature flag (#…
Browse files Browse the repository at this point in the history
…193)

* wip feature flags

* feat: Fetch user info every 24h

It starts storing `USER_FEATURE_FLAGS` existing feature flags

* Write feature flags so they can be read as a slice

* feat: Add command with feature flag required

* chore: update to latest meroxa-go

* feat: update error message

Co-authored-by: Diana Doherty <[email protected]>

Co-authored-by: Diana Doherty <[email protected]>
  • Loading branch information
raulb and Diana Doherty authored Sep 29, 2021
1 parent e8c3e26 commit d37a7db
Show file tree
Hide file tree
Showing 15 changed files with 208 additions and 49 deletions.
57 changes: 54 additions & 3 deletions cmd/meroxa/builder/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import (

type Command interface {
// Usage is the one-line usage message.
// Recommended syntax is as follow:
// Recommended syntax is as follows:
// [ ] identifies an optional argument. Arguments that are not enclosed in brackets are required.
// ... indicates that you can specify multiple values for the previous argument.
// | indicates mutually exclusive information. You can use the argument to the left of the separator or the
Expand Down Expand Up @@ -162,6 +162,11 @@ type CommandWithSubCommands interface {
SubCommands() []*cobra.Command
}

type CommandWithFeatureFlag interface {
Command
FeatureFlag() string
}

// BuildCobraCommand takes a Command and builds a *cobra.Command from it. It figures out if the command implements any
// other CommandWith* interfaces and configures the cobra command accordingly.
func BuildCobraCommand(c Command) *cobra.Command {
Expand All @@ -173,16 +178,21 @@ func BuildCobraCommand(c Command) *cobra.Command {
buildCommandWithArgs(cmd, c)
buildCommandWithClient(cmd, c)
buildCommandWithConfig(cmd, c)

// buildCommandWithConfirm needs to go before buildCommandWithExecute to make sure there's a confirmation prompt
// prior to execution.
buildCommandWithConfirm(cmd, c)
buildCommandWithDocs(cmd, c)
buildCommandWithFeatureFlag(cmd, c)
buildCommandWithExecute(cmd, c)

buildCommandWithDocs(cmd, c)
buildCommandWithFlags(cmd, c)
buildCommandWithHidden(cmd, c)
buildCommandWithLogger(cmd, c)
buildCommandWithNoHeaders(cmd, c)
buildCommandWithSubCommands(cmd, c)

// this has to be the last function so it captures all errors from RunE
// this has to be the last function, so it captures all errors from RunE
buildCommandEvent(cmd, c)

return cmd
Expand Down Expand Up @@ -586,3 +596,44 @@ func buildCommandWithSubCommands(cmd *cobra.Command, c Command) {
cmd.AddCommand(sub)
}
}

func hasFeatureFlag(flags []string, f string) bool {
for _, v := range flags {
if v == f {
return true
}
}

return false
}

func buildCommandWithFeatureFlag(cmd *cobra.Command, c Command) {
v, ok := c.(CommandWithFeatureFlag)
if !ok {
return
}

// Considering a command with feature flags not ready to be documented and
// publicly available.
cmd.Hidden = true

old := cmd.PreRunE
cmd.PreRunE = func(cmd *cobra.Command, args []string) error {
if old != nil {
err := old(cmd, args)
if err != nil {
return err
}
}

flagRequired := v.FeatureFlag()
userFeatureFlags := global.Config.GetStringSlice(global.UserFeatureFlagsEnv)

if !hasFeatureFlag(userFeatureFlags, flagRequired) {
return fmt.Errorf("your account does not have access to the %q feature."+
"Reach out to [email protected] for more information", flagRequired)
}

return nil
}
}
43 changes: 33 additions & 10 deletions cmd/meroxa/global/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"errors"
"fmt"
"os"
"strings"
"time"

"github.com/spf13/viper"
Expand All @@ -29,24 +30,39 @@ import (
"golang.org/x/oauth2"
)

func noUserInfo(actor, actorUUID string) bool {
return actor == "" || actorUUID == ""
}

// userInfoStale checks if user information was updated within a 24h period.
func userInfoStale() bool {
updatedAt := Config.GetTime(UserInfoUpdatedAtEnv)
if updatedAt.IsZero() {
return true
}

duration := time.Now().UTC().Sub(updatedAt)
return duration.Hours() > 24 // nolint:gomnd
}

func GetCLIUserInfo() (actor, actorUUID string, err error) {
// Require login
_, _, err = GetUserToken()

/*
We don't report client issues to the customer as it'll likely require `meroxa login` for any command.
There are command that don't require client such as `meroxa env`, and we wouldn't like to throw an error,
There are command that don't require client such as `meroxa config`, and we wouldn't like to throw an error,
just because we can't emit events.
*/
if err != nil {
return "", "", nil
}

// fetch actor account.
actor = Config.GetString("ACTOR")
actorUUID = Config.GetString("ACTOR_UUID")
actor = Config.GetString(ActorEnv)
actorUUID = Config.GetString(ActorUUIDEnv)

if actor == "" || actorUUID == "" {
if noUserInfo(actor, actorUUID) || userInfoStale() {
// call api to fetch
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) // nolint:gomnd
defer cancel()
Expand All @@ -66,8 +82,15 @@ func GetCLIUserInfo() (actor, actorUUID string, err error) {
actor = account.Email
actorUUID = account.UUID

Config.Set("ACTOR", actor)
Config.Set("ACTOR_UUID", actorUUID)
// write user information in config file
Config.Set(ActorEnv, actor)
Config.Set(ActorUUIDEnv, actorUUID)

// write existing feature flags enabled
Config.Set(UserFeatureFlagsEnv, strings.Join(account.Features, " "))

// write when was the last time we updated user info
Config.Set(UserInfoUpdatedAtEnv, time.Now().UTC())

err = Config.WriteConfig()

Expand All @@ -85,8 +108,8 @@ func GetCLIUserInfo() (actor, actorUUID string, err error) {
}

func GetUserToken() (accessToken, refreshToken string, err error) {
accessToken = Config.GetString("ACCESS_TOKEN")
refreshToken = Config.GetString("REFRESH_TOKEN")
accessToken = Config.GetString(AccessTokenEnv)
refreshToken = Config.GetString(RefreshTokenEnv)
if accessToken == "" && refreshToken == "" {
// we need at least one token for creating an authenticated client
return "", "", errors.New("please login or signup by running 'meroxa login'")
Expand Down Expand Up @@ -140,7 +163,7 @@ func oauthEndpoint(domain string) oauth2.Endpoint {

// onTokenRefreshed tries to save the new token in the config.
func onTokenRefreshed(token *oauth2.Token) {
Config.Set("ACCESS_TOKEN", token.AccessToken)
Config.Set("REFRESH_TOKEN", token.RefreshToken)
Config.Set(AccessTokenEnv, token.AccessToken)
Config.Set(RefreshTokenEnv, token.RefreshToken)
_ = Config.WriteConfig() // ignore error, it's a best effort
}
14 changes: 11 additions & 3 deletions cmd/meroxa/global/global.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,17 @@ var (
flagJSON bool
)

func DeprecateV1Commands() bool {
return true
}
const (
AccessTokenEnv = "ACCESS_TOKEN"
ActorEnv = "ACTOR"
ActorUUIDEnv = "ACTOR_UUID"
CasedDebugEnv = "CASED_DEBUG"
CasedPublishKeyEnv = "CASED_PUBLISH_KEY"
PublishMetricsEnv = "PUBLISH_METRICS"
RefreshTokenEnv = "REFRESH_TOKEN"
UserFeatureFlagsEnv = "USER_FEATURE_FLAGS"
UserInfoUpdatedAtEnv = "USER_INFO_UPDATED_AT"
)

func RegisterGlobalFlags(cmd *cobra.Command) {
cmd.PersistentFlags().BoolVar(&flagJSON, "json", false, "output json")
Expand Down
10 changes: 5 additions & 5 deletions cmd/meroxa/global/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,19 @@ import (
func NewPublisher() cased.Publisher {
var options []cased.PublisherOption

casedAPIKey := Config.GetString("CASED_PUBLISH_KEY")
casedAPIKey := Config.GetString(CasedPublishKeyEnv)

if casedAPIKey != "" {
options = append(options, cased.WithPublishKey(casedAPIKey))
} else {
options = append(options, cased.WithPublishURL(fmt.Sprintf("%s/telemetry", GetMeroxaAPIURL())))
}

if v := Config.GetString("PUBLISH_METRICS"); v == "false" {
if v := Config.GetString(PublishMetricsEnv); v == "false" {
options = append(options, cased.WithSilence(true))
}

if v := Config.GetBool("CASED_DEBUG"); v {
if v := Config.GetBool(CasedDebugEnv); v {
options = append(options, cased.WithDebug(v))
}

Expand Down Expand Up @@ -166,7 +166,7 @@ var (
// PublishEvent will take care of publishing the event to Cased.
func publishEvent(event cased.AuditEvent) {
// Only prints out to console
if v := Config.GetString("PUBLISH_METRICS"); v == "stdout" {
if v := Config.GetString(PublishMetricsEnv); v == "stdout" {
e, _ := json.Marshal(event)
fmt.Printf("\n\nEvent: %v\n\n", string(e))
return
Expand All @@ -178,7 +178,7 @@ func publishEvent(event cased.AuditEvent) {
err := cased.Publish(event)
if err != nil {
// cased.Publish could return an error, but we only show it when debugging
if Config.GetBool("CASED_DEBUG") {
if Config.GetBool(CasedDebugEnv) {
fmt.Println("error: %w", err)
}
return
Expand Down
25 changes: 14 additions & 11 deletions cmd/meroxa/global/metrics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ func TestNewPublisherWithCasedAPIKey(t *testing.T) {
defer clearConfiguration()

apiKey := "8c32e3b7-d0e7-4650-a82b-e85e6a8d56fa"
Config.Set("CASED_PUBLISH_KEY", apiKey)
Config.Set(CasedPublishKeyEnv, apiKey)

got := NewPublisher()

Expand Down Expand Up @@ -214,14 +214,14 @@ func TestNewPublisherPublishing(t *testing.T) {
Config = viper.New()
defer clearConfiguration()

Config.Set("PUBLISH_METRICS", "false")
Config.Set(PublishMetricsEnv, "false")
got := NewPublisher()

if !got.Options().Silence {
t.Fatalf("expected publisher silence option to be %v", true)
}

Config.Set("PUBLISH_METRICS", "any-other-value")
Config.Set(PublishMetricsEnv, "any-other-value")
got = NewPublisher()

if got.Options().Silence {
Expand All @@ -233,7 +233,7 @@ func TestNewPublisherWithDebug(t *testing.T) {
Config = viper.New()
defer clearConfiguration()

Config.Set("CASED_DEBUG", true)
Config.Set(CasedDebugEnv, true)
got := NewPublisher()

if !got.Options().Debug {
Expand All @@ -249,7 +249,7 @@ func TestPublishEventOnStdout(t *testing.T) {
Config = viper.New()
defer clearConfiguration()

Config.Set("PUBLISH_METRICS", "stdout")
Config.Set(PublishMetricsEnv, "stdout")

event := cased.AuditEvent{
"key": "event",
Expand Down Expand Up @@ -302,22 +302,25 @@ func TestAddUserInfo(t *testing.T) {
meroxaUserUUID := "ff45a74a-4fc1-49a5-8fa5-f1762703b7e8"

// Makes sure user is logged in
Config.Set("ACCESS_TOKEN", "access-token")
Config.Set("REFRESH_TOKEN", "refresh-token")
Config.Set(AccessTokenEnv, "access-token")
Config.Set(RefreshTokenEnv, "refresh-token")

Config.Set("ACTOR", meroxaUser)
Config.Set("ACTOR_UUID", meroxaUserUUID)
Config.Set(ActorEnv, meroxaUser)
Config.Set(ActorUUIDEnv, meroxaUserUUID)

// Makes sure there's no need to fetch for user info
Config.Set(UserInfoUpdatedAtEnv, time.Now().UTC())

event := cased.AuditEvent{}
addUserInfo(event)

want := meroxaUser
if v := event["actor"]; v != want {
t.Fatalf("expected event action to be %q, got %q", want, v)
t.Fatalf("expected event \"actor\" to be %q, got %q", want, v)
}

want = meroxaUserUUID
if v := event["actor_uuid"]; v != want {
t.Fatalf("expected event action to be %q, got %q", want, v)
t.Fatalf("expected event \"actor_uuid\" to be %q, got %q", want, v)
}
}
4 changes: 2 additions & 2 deletions cmd/meroxa/root/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,8 @@ func (l *Login) authorizeUser(ctx context.Context, clientID, authDomain, audienc
return
}

l.config.Set("ACCESS_TOKEN", accessToken)
l.config.Set("REFRESH_TOKEN", refreshToken)
l.config.Set(global.AccessTokenEnv, accessToken)
l.config.Set(global.RefreshTokenEnv, refreshToken)

// return an indication of success to the caller
_, _ = io.WriteString(w, `
Expand Down
8 changes: 4 additions & 4 deletions cmd/meroxa/root/auth/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ package auth
import (
"context"

"github.com/meroxa/cli/cmd/meroxa/builder"
"github.com/meroxa/cli/cmd/meroxa/global"
"github.com/meroxa/cli/config"
"github.com/meroxa/cli/log"

"github.com/meroxa/cli/cmd/meroxa/builder"
)

var (
Expand Down Expand Up @@ -56,8 +56,8 @@ func (l *Logout) Config(cfg config.Config) {
}

func (l *Logout) Execute(ctx context.Context) error {
l.config.Set("ACCESS_TOKEN", "")
l.config.Set("REFRESH_TOKEN", "")
l.config.Set(global.AccessTokenEnv, "")
l.config.Set(global.RefreshTokenEnv, "")

l.logger.Infof(ctx, "Successfully logged out.")
return nil
Expand Down
10 changes: 8 additions & 2 deletions cmd/meroxa/root/auth/whoami.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ package auth

import (
"context"
"strings"
"time"

"github.com/meroxa/cli/cmd/meroxa/global"

"github.com/meroxa/cli/cmd/meroxa/builder"
"github.com/meroxa/cli/config"
Expand Down Expand Up @@ -77,8 +81,10 @@ func (w *WhoAmI) Execute(ctx context.Context) error {
w.logger.JSON(ctx, user)

// Updates config file with actor information.
w.config.Set("ACTOR", user.Email)
w.config.Set("ACTOR_UUID", user.UUID)
w.config.Set(global.ActorEnv, user.Email)
w.config.Set(global.ActorUUIDEnv, user.UUID)
w.config.Set(global.UserFeatureFlagsEnv, strings.Join(user.Features, " "))
w.config.Set(global.UserInfoUpdatedAtEnv, time.Now().UTC())

if err != nil {
return err
Expand Down
Loading

0 comments on commit d37a7db

Please sign in to comment.