diff --git a/internal/config/config.go b/internal/config/config.go index a992143..99c1498 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -136,10 +136,17 @@ type Keys struct { } type AuthProvider struct { - Issuer string `yaml:"issuer"` - ClientID string `yaml:"client_id"` - ClientSecret string `yaml:"client_secret"` - Scopes []string `yaml:"additional_scopes"` + Issuer string `yaml:"issuer"` + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + Scopes []string `yaml:"additional_scopes"` + SystemAdminPolicy SystemAdminPolicy `yaml:"system_admins"` +} + +type SystemAdminPolicy struct { + Subs []string `json:"subs,omitempty"` + Emails []string `json:"emails,omitempty"` + Filters []string `json:"filters,omitempty"` } func (c *Config) CreateUrl(format string, a ...interface{}) string { diff --git a/internal/database/database.go b/internal/database/database.go index c1f8043..cc8dae8 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -45,6 +45,7 @@ func migrate(db *gorm.DB, repository domain.Repository) error { &domain.Tailnet{}, &domain.Account{}, &domain.User{}, + &domain.SystemApiKey{}, &domain.ApiKey{}, &domain.AuthKey{}, &domain.Machine{}, diff --git a/internal/domain/repository.go b/internal/domain/repository.go index 9d5ee95..68dc9c2 100644 --- a/internal/domain/repository.go +++ b/internal/domain/repository.go @@ -20,6 +20,9 @@ type Repository interface { ListTailnets(ctx context.Context) ([]Tailnet, error) DeleteTailnet(ctx context.Context, id uint64) error + SaveSystemApiKey(ctx context.Context, key *SystemApiKey) error + LoadSystemApiKey(ctx context.Context, key string) (*SystemApiKey, error) + SaveApiKey(ctx context.Context, key *ApiKey) error LoadApiKey(ctx context.Context, key string) (*ApiKey, error) DeleteApiKeysByTailnet(ctx context.Context, tailnetID uint64) error diff --git a/internal/domain/system_api_key.go b/internal/domain/system_api_key.go new file mode 100644 index 0000000..2d7d53e --- /dev/null +++ b/internal/domain/system_api_key.go @@ -0,0 +1,91 @@ +package domain + +import ( + "context" + "errors" + "fmt" + "github.com/jsiebens/ionscale/internal/util" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + "strings" + "time" +) + +func CreateSystemApiKey(account *Account, expiresAt *time.Time) (string, *SystemApiKey) { + key := util.RandStringBytes(12) + pwd := util.RandStringBytes(22) + value := fmt.Sprintf("sk_%s_%s", key, pwd) + + hash, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost) + if err != nil { + panic(err) + } + + return value, &SystemApiKey{ + ID: util.NextID(), + Key: key, + Hash: string(hash), + CreatedAt: time.Now().UTC(), + ExpiresAt: expiresAt, + + AccountID: account.ID, + } +} + +type SystemApiKey struct { + ID uint64 `gorm:"primary_key;autoIncrement:false"` + Key string `gorm:"type:varchar(64);unique_index"` + Hash string + + CreatedAt time.Time + ExpiresAt *time.Time + + AccountID uint64 + Account Account +} + +func (r *repository) SaveSystemApiKey(ctx context.Context, key *SystemApiKey) error { + tx := r.withContext(ctx).Save(key) + + if tx.Error != nil { + return tx.Error + } + + return nil +} + +func (r *repository) LoadSystemApiKey(ctx context.Context, token string) (*SystemApiKey, error) { + split := strings.Split(token, "_") + if len(split) != 3 { + return nil, nil + } + + prefix := split[0] + key := split[1] + value := split[2] + + if prefix != "sk" { + return nil, nil + } + + var m SystemApiKey + tx := r.withContext(ctx).Preload("Account").First(&m, "key = ?", key) + + if errors.Is(tx.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + + if tx.Error != nil { + return nil, tx.Error + } + + if err := bcrypt.CompareHashAndPassword([]byte(m.Hash), []byte(value)); err != nil { + return nil, nil + } + + if !m.ExpiresAt.IsZero() && m.ExpiresAt.Before(time.Now()) { + return nil, nil + } + + return &m, nil +} diff --git a/internal/handlers/authentication.go b/internal/handlers/authentication.go index 989f57f..e8f930a 100644 --- a/internal/handlers/authentication.go +++ b/internal/handlers/authentication.go @@ -22,11 +22,14 @@ import ( func NewAuthenticationHandlers( config *config.Config, authProvider provider.AuthProvider, + systemIAMPolicy *domain.IAMPolicy, repository domain.Repository) *AuthenticationHandlers { + return &AuthenticationHandlers{ config: config, authProvider: authProvider, repository: repository, + systemIAMPolicy: systemIAMPolicy, pendingOAuthUsers: cache.New(5*time.Minute, 10*time.Minute), } } @@ -35,6 +38,7 @@ type AuthenticationHandlers struct { repository domain.Repository authProvider provider.AuthProvider config *config.Config + systemIAMPolicy *domain.IAMPolicy pendingOAuthUsers *cache.Cache } @@ -43,7 +47,8 @@ type AuthFormData struct { } type TailnetSelectionData struct { - Tailnets []domain.Tailnet + Tailnets []domain.Tailnet + SystemAdmin bool } type oauthState struct { @@ -128,12 +133,17 @@ func (h *AuthenticationHandlers) Callback(c echo.Context) error { return err } + isSystemAdmin, err := h.isSystemAdmin(ctx, user) + if err != nil { + return err + } + tailnets, err := h.listAvailableTailnets(ctx, user) if err != nil { return err } - if len(tailnets) == 0 { + if !isSystemAdmin && len(tailnets) == 0 { if state.Flow == "r" { req, err := h.repository.GetRegistrationRequestByKey(ctx, state.Key) if err == nil && req != nil { @@ -157,7 +167,11 @@ func (h *AuthenticationHandlers) Callback(c echo.Context) error { h.pendingOAuthUsers.Set(state.Key, account, cache.DefaultExpiration) - return c.Render(http.StatusOK, "tailnets.html", &TailnetSelectionData{Tailnets: tailnets}) + return c.Render(http.StatusOK, "tailnets.html", &TailnetSelectionData{Tailnets: tailnets, SystemAdmin: isSystemAdmin}) +} + +func (h *AuthenticationHandlers) isSystemAdmin(ctx context.Context, u *provider.User) (bool, error) { + return h.systemIAMPolicy.EvaluatePolicy(&domain.Identity{UserID: u.ID, Email: u.Name, Attr: u.Attr}) } func (h *AuthenticationHandlers) listAvailableTailnets(ctx context.Context, u *provider.User) ([]domain.Tailnet, error) { @@ -221,6 +235,34 @@ func (h *AuthenticationHandlers) Error(c echo.Context) error { func (h *AuthenticationHandlers) endCliAuthenticationFlow(c echo.Context, req *domain.AuthenticationRequest, state *oauthState) error { ctx := c.Request().Context() + item, ok := h.pendingOAuthUsers.Get(state.Key) + if !ok { + return c.Redirect(http.StatusFound, "/a/error") + } + + oa := item.(*domain.Account) + + // continue as system admin? + if c.FormValue("a") == "true" { + expiresAt := time.Now().Add(24 * time.Hour) + token, apiKey := domain.CreateSystemApiKey(oa, &expiresAt) + req.Token = token + + err := h.repository.Transaction(func(rp domain.Repository) error { + if err := rp.SaveSystemApiKey(ctx, apiKey); err != nil { + return err + } + if err := rp.SaveAuthenticationRequest(ctx, req); err != nil { + return err + } + return nil + }) + if err != nil { + return err + } + return c.Redirect(http.StatusFound, "/a/success") + } + tailnetIDParam := c.FormValue("s") parseUint, err := strconv.ParseUint(tailnetIDParam, 10, 64) @@ -232,13 +274,6 @@ func (h *AuthenticationHandlers) endCliAuthenticationFlow(c echo.Context, req *d return err } - item, ok := h.pendingOAuthUsers.Get(state.Key) - if !ok { - return c.Redirect(http.StatusFound, "/a/error") - } - - oa := item.(*domain.Account) - user, _, err := h.repository.GetOrCreateUserWithAccount(ctx, tailnet, oa) if err != nil { return err diff --git a/internal/server/server.go b/internal/server/server.go index 73ebe4d..353e046 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -10,6 +10,7 @@ import ( "github.com/jsiebens/ionscale/internal/broker" "github.com/jsiebens/ionscale/internal/config" "github.com/jsiebens/ionscale/internal/database" + "github.com/jsiebens/ionscale/internal/domain" "github.com/jsiebens/ionscale/internal/handlers" "github.com/jsiebens/ionscale/internal/provider" "github.com/jsiebens/ionscale/internal/service" @@ -83,7 +84,7 @@ func Start(c *config.Config) error { return e } - authProvider, err := setupAuthProvider(c.AuthProvider) + authProvider, systemIAMPolicy, err := setupAuthProvider(c.AuthProvider) if err != nil { return err } @@ -94,6 +95,7 @@ func Start(c *config.Config) error { authenticationHandlers := handlers.NewAuthenticationHandlers( c, authProvider, + systemIAMPolicy, repository, ) @@ -176,11 +178,21 @@ func Start(c *config.Config) error { return g.Wait() } -func setupAuthProvider(config config.AuthProvider) (provider.AuthProvider, error) { +func setupAuthProvider(config config.AuthProvider) (provider.AuthProvider, *domain.IAMPolicy, error) { if len(config.Issuer) == 0 { - return nil, nil + return nil, &domain.IAMPolicy{}, nil } - return provider.NewOIDCProvider(&config) + + authProvider, err := provider.NewOIDCProvider(&config) + if err != nil { + return nil, nil, err + } + + return authProvider, &domain.IAMPolicy{ + Subs: config.SystemAdminPolicy.Subs, + Emails: config.SystemAdminPolicy.Emails, + Filters: config.SystemAdminPolicy.Filters, + }, nil } func metricsListener(config *config.Config) (net.Listener, error) { diff --git a/internal/service/interceptors.go b/internal/service/interceptors.go index 832677b..134b17a 100644 --- a/internal/service/interceptors.go +++ b/internal/service/interceptors.go @@ -82,13 +82,18 @@ func exchangeToken(ctx context.Context, systemAdminKey key.ServerPrivate, reposi } apiKey, err := repository.LoadApiKey(ctx, value) - if err != nil || apiKey == nil { - return nil + if err == nil && apiKey != nil { + user := apiKey.User + tailnet := apiKey.Tailnet + role := tailnet.IAMPolicy.GetRole(user) + + return &Principal{User: &apiKey.User, SystemRole: domain.SystemRoleNone, UserRole: role} } - user := apiKey.User - tailnet := apiKey.Tailnet - role := tailnet.IAMPolicy.GetRole(user) + systemApiKey, err := repository.LoadSystemApiKey(ctx, value) + if err == nil && systemApiKey != nil { + return &Principal{SystemRole: domain.SystemRoleAdmin} + } - return &Principal{User: &apiKey.User, SystemRole: domain.SystemRoleNone, UserRole: role} + return nil } diff --git a/internal/templates/tailnets.html b/internal/templates/tailnets.html index 7a14e82..34a8ab4 100644 --- a/internal/templates/tailnets.html +++ b/internal/templates/tailnets.html @@ -74,10 +74,29 @@
System Admin
+ You are a member of the System Admin group: +Tailnets
Select your tailnet: