mirror of
https://github.com/coder/coder.git
synced 2025-07-06 15:41:45 +00:00
* chore: More complete tracing for RBAC functions * Add input.json as example rbac input for rego cli The input.json is required to play with the rego cli and debug the policy without golang. It is good to have an example to run the commands in the readme.md * Add span events to capture authorize and prepared results * chore: Add prometheus metrics to rbac authorizer
418 lines
12 KiB
Go
418 lines
12 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/cenkalti/backoff/v4"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
|
|
"cdr.dev/slog"
|
|
"github.com/coder/coder/coderd"
|
|
agplaudit "github.com/coder/coder/coderd/audit"
|
|
"github.com/coder/coder/coderd/httpapi"
|
|
"github.com/coder/coder/coderd/httpmw"
|
|
"github.com/coder/coder/coderd/rbac"
|
|
"github.com/coder/coder/codersdk"
|
|
"github.com/coder/coder/enterprise/coderd/license"
|
|
"github.com/coder/coder/enterprise/derpmesh"
|
|
"github.com/coder/coder/enterprise/replicasync"
|
|
"github.com/coder/coder/enterprise/tailnet"
|
|
"github.com/coder/coder/provisionerd/proto"
|
|
agpltailnet "github.com/coder/coder/tailnet"
|
|
)
|
|
|
|
// New constructs an Enterprise coderd API instance.
|
|
// This handler is designed to wrap the AGPL Coder code and
|
|
// layer Enterprise functionality on top as much as possible.
|
|
func New(ctx context.Context, options *Options) (*API, error) {
|
|
if options.EntitlementsUpdateInterval == 0 {
|
|
options.EntitlementsUpdateInterval = 10 * time.Minute
|
|
}
|
|
if options.Keys == nil {
|
|
options.Keys = Keys
|
|
}
|
|
if options.Options == nil {
|
|
options.Options = &coderd.Options{}
|
|
}
|
|
if options.PrometheusRegistry == nil {
|
|
options.PrometheusRegistry = prometheus.NewRegistry()
|
|
}
|
|
if options.Options.Authorizer == nil {
|
|
options.Options.Authorizer = rbac.NewAuthorizer(options.PrometheusRegistry)
|
|
}
|
|
ctx, cancelFunc := context.WithCancel(ctx)
|
|
api := &API{
|
|
AGPL: coderd.New(options.Options),
|
|
Options: options,
|
|
cancelEntitlementsLoop: cancelFunc,
|
|
}
|
|
|
|
oauthConfigs := &httpmw.OAuth2Configs{
|
|
Github: options.GithubOAuth2Config,
|
|
OIDC: options.OIDCConfig,
|
|
}
|
|
apiKeyMiddleware := httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
|
|
DB: options.Database,
|
|
OAuth2Configs: oauthConfigs,
|
|
RedirectToLogin: false,
|
|
})
|
|
|
|
api.AGPL.APIHandler.Group(func(r chi.Router) {
|
|
r.Get("/entitlements", api.serveEntitlements)
|
|
r.Route("/replicas", func(r chi.Router) {
|
|
r.Use(apiKeyMiddleware)
|
|
r.Get("/", api.replicas)
|
|
})
|
|
r.Route("/licenses", func(r chi.Router) {
|
|
r.Use(apiKeyMiddleware)
|
|
r.Post("/", api.postLicense)
|
|
r.Get("/", api.licenses)
|
|
r.Delete("/{id}", api.deleteLicense)
|
|
})
|
|
r.Route("/organizations/{organization}/groups", func(r chi.Router) {
|
|
r.Use(
|
|
apiKeyMiddleware,
|
|
api.templateRBACEnabledMW,
|
|
httpmw.ExtractOrganizationParam(api.Database),
|
|
)
|
|
r.Post("/", api.postGroupByOrganization)
|
|
r.Get("/", api.groupsByOrganization)
|
|
r.Route("/{groupName}", func(r chi.Router) {
|
|
r.Use(
|
|
httpmw.ExtractGroupByNameParam(api.Database),
|
|
)
|
|
|
|
r.Get("/", api.groupByOrganization)
|
|
})
|
|
})
|
|
r.Route("/organizations/{organization}/provisionerdaemons", func(r chi.Router) {
|
|
r.Use(
|
|
api.provisionerDaemonsEnabledMW,
|
|
apiKeyMiddleware,
|
|
httpmw.ExtractOrganizationParam(api.Database),
|
|
)
|
|
r.Get("/", api.provisionerDaemons)
|
|
r.Get("/serve", api.provisionerDaemonServe)
|
|
})
|
|
r.Route("/templates/{template}/acl", func(r chi.Router) {
|
|
r.Use(
|
|
api.templateRBACEnabledMW,
|
|
apiKeyMiddleware,
|
|
httpmw.ExtractTemplateParam(api.Database),
|
|
)
|
|
r.Get("/", api.templateACL)
|
|
r.Patch("/", api.patchTemplateACL)
|
|
})
|
|
r.Route("/groups/{group}", func(r chi.Router) {
|
|
r.Use(
|
|
api.templateRBACEnabledMW,
|
|
apiKeyMiddleware,
|
|
httpmw.ExtractGroupParam(api.Database),
|
|
)
|
|
r.Get("/", api.group)
|
|
r.Patch("/", api.patchGroup)
|
|
r.Delete("/", api.deleteGroup)
|
|
})
|
|
r.Route("/workspace-quota", func(r chi.Router) {
|
|
r.Use(
|
|
apiKeyMiddleware,
|
|
)
|
|
r.Route("/{user}", func(r chi.Router) {
|
|
r.Use(httpmw.ExtractUserParam(options.Database, false))
|
|
r.Get("/", api.workspaceQuota)
|
|
})
|
|
})
|
|
r.Route("/appearance", func(r chi.Router) {
|
|
r.Use(
|
|
apiKeyMiddleware,
|
|
)
|
|
r.Get("/", api.appearance)
|
|
r.Put("/", api.putAppearance)
|
|
})
|
|
})
|
|
|
|
if len(options.SCIMAPIKey) != 0 {
|
|
api.AGPL.RootHandler.Route("/scim/v2", func(r chi.Router) {
|
|
r.Use(api.scimEnabledMW)
|
|
r.Post("/Users", api.scimPostUser)
|
|
r.Route("/Users", func(r chi.Router) {
|
|
r.Get("/", api.scimGetUsers)
|
|
r.Post("/", api.scimPostUser)
|
|
r.Get("/{id}", api.scimGetUser)
|
|
r.Patch("/{id}", api.scimPatchUser)
|
|
})
|
|
})
|
|
}
|
|
|
|
meshRootCA := x509.NewCertPool()
|
|
for _, certificate := range options.TLSCertificates {
|
|
for _, certificatePart := range certificate.Certificate {
|
|
certificate, err := x509.ParseCertificate(certificatePart)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("parse certificate %s: %w", certificate.Subject.CommonName, err)
|
|
}
|
|
meshRootCA.AddCert(certificate)
|
|
}
|
|
}
|
|
// This TLS configuration spoofs access from the access URL hostname
|
|
// assuming that the certificates provided will cover that hostname.
|
|
//
|
|
// Replica sync and DERP meshing require accessing replicas via their
|
|
// internal IP addresses, and if TLS is configured we use the same
|
|
// certificates.
|
|
meshTLSConfig := &tls.Config{
|
|
MinVersion: tls.VersionTLS12,
|
|
Certificates: options.TLSCertificates,
|
|
RootCAs: meshRootCA,
|
|
ServerName: options.AccessURL.Hostname(),
|
|
}
|
|
var err error
|
|
api.replicaManager, err = replicasync.New(ctx, options.Logger, options.Database, options.Pubsub, &replicasync.Options{
|
|
ID: api.AGPL.ID,
|
|
RelayAddress: options.DERPServerRelayAddress,
|
|
RegionID: int32(options.DERPServerRegionID),
|
|
TLSConfig: meshTLSConfig,
|
|
})
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("initialize replica: %w", err)
|
|
}
|
|
api.derpMesh = derpmesh.New(options.Logger.Named("derpmesh"), api.DERPServer, meshTLSConfig)
|
|
|
|
err = api.updateEntitlements(ctx)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("update entitlements: %w", err)
|
|
}
|
|
go api.runEntitlementsLoop(ctx)
|
|
|
|
return api, nil
|
|
}
|
|
|
|
type Options struct {
|
|
*coderd.Options
|
|
|
|
RBAC bool
|
|
AuditLogging bool
|
|
// Whether to block non-browser connections.
|
|
BrowserOnly bool
|
|
SCIMAPIKey []byte
|
|
|
|
// Used for high availability.
|
|
DERPServerRelayAddress string
|
|
DERPServerRegionID int
|
|
|
|
EntitlementsUpdateInterval time.Duration
|
|
Keys map[string]ed25519.PublicKey
|
|
}
|
|
|
|
type API struct {
|
|
AGPL *coderd.API
|
|
*Options
|
|
|
|
// Detects multiple Coder replicas running at the same time.
|
|
replicaManager *replicasync.Manager
|
|
// Meshes DERP connections from multiple replicas.
|
|
derpMesh *derpmesh.Mesh
|
|
|
|
cancelEntitlementsLoop func()
|
|
entitlementsMu sync.RWMutex
|
|
entitlements codersdk.Entitlements
|
|
}
|
|
|
|
func (api *API) Close() error {
|
|
api.cancelEntitlementsLoop()
|
|
_ = api.replicaManager.Close()
|
|
_ = api.derpMesh.Close()
|
|
return api.AGPL.Close()
|
|
}
|
|
|
|
func (api *API) updateEntitlements(ctx context.Context) error {
|
|
api.entitlementsMu.Lock()
|
|
defer api.entitlementsMu.Unlock()
|
|
|
|
entitlements, err := license.Entitlements(ctx, api.Database, api.Logger, len(api.replicaManager.All()), len(api.GitAuthConfigs), api.Keys, map[string]bool{
|
|
codersdk.FeatureAuditLog: api.AuditLogging,
|
|
codersdk.FeatureBrowserOnly: api.BrowserOnly,
|
|
codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0,
|
|
codersdk.FeatureHighAvailability: api.DERPServerRelayAddress != "",
|
|
codersdk.FeatureMultipleGitAuth: len(api.GitAuthConfigs) > 1,
|
|
codersdk.FeatureTemplateRBAC: api.RBAC,
|
|
codersdk.FeatureExternalProvisionerDaemons: true,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entitlements.Experimental = api.DeploymentConfig.Experimental.Value
|
|
|
|
featureChanged := func(featureName string) (changed bool, enabled bool) {
|
|
if api.entitlements.Features == nil {
|
|
return true, entitlements.Features[featureName].Enabled
|
|
}
|
|
oldFeature := api.entitlements.Features[featureName]
|
|
newFeature := entitlements.Features[featureName]
|
|
if oldFeature.Enabled != newFeature.Enabled {
|
|
return true, newFeature.Enabled
|
|
}
|
|
return false, newFeature.Enabled
|
|
}
|
|
|
|
if changed, enabled := featureChanged(codersdk.FeatureAuditLog); changed {
|
|
auditor := agplaudit.NewNop()
|
|
if enabled {
|
|
auditor = api.AGPL.Options.Auditor
|
|
}
|
|
api.AGPL.Auditor.Store(&auditor)
|
|
}
|
|
|
|
if changed, enabled := featureChanged(codersdk.FeatureBrowserOnly); changed {
|
|
var handler func(rw http.ResponseWriter) bool
|
|
if enabled {
|
|
handler = api.shouldBlockNonBrowserConnections
|
|
}
|
|
api.AGPL.WorkspaceClientCoordinateOverride.Store(&handler)
|
|
}
|
|
|
|
if changed, enabled := featureChanged(codersdk.FeatureTemplateRBAC); changed {
|
|
if enabled {
|
|
committer := committer{Database: api.Database}
|
|
ptr := proto.QuotaCommitter(&committer)
|
|
api.AGPL.QuotaCommitter.Store(&ptr)
|
|
} else {
|
|
api.AGPL.QuotaCommitter.Store(nil)
|
|
}
|
|
}
|
|
|
|
if changed, enabled := featureChanged(codersdk.FeatureHighAvailability); changed {
|
|
coordinator := agpltailnet.NewCoordinator()
|
|
if enabled {
|
|
haCoordinator, err := tailnet.NewCoordinator(api.Logger, api.Pubsub)
|
|
if err != nil {
|
|
api.Logger.Error(ctx, "unable to set up high availability coordinator", slog.Error(err))
|
|
// If we try to setup the HA coordinator and it fails, nothing
|
|
// is actually changing.
|
|
changed = false
|
|
} else {
|
|
coordinator = haCoordinator
|
|
}
|
|
|
|
api.replicaManager.SetCallback(func() {
|
|
addresses := make([]string, 0)
|
|
for _, replica := range api.replicaManager.Regional() {
|
|
addresses = append(addresses, replica.RelayAddress)
|
|
}
|
|
api.derpMesh.SetAddresses(addresses, false)
|
|
_ = api.updateEntitlements(ctx)
|
|
})
|
|
} else {
|
|
api.derpMesh.SetAddresses([]string{}, false)
|
|
api.replicaManager.SetCallback(func() {
|
|
// If the amount of replicas change, so should our entitlements.
|
|
// This is to display a warning in the UI if the user is unlicensed.
|
|
_ = api.updateEntitlements(ctx)
|
|
})
|
|
}
|
|
|
|
// Recheck changed in case the HA coordinator failed to set up.
|
|
if changed {
|
|
oldCoordinator := *api.AGPL.TailnetCoordinator.Swap(&coordinator)
|
|
err := oldCoordinator.Close()
|
|
if err != nil {
|
|
api.Logger.Error(ctx, "close old tailnet coordinator", slog.Error(err))
|
|
}
|
|
}
|
|
}
|
|
|
|
api.entitlements = entitlements
|
|
|
|
return nil
|
|
}
|
|
|
|
// @Summary Get entitlements
|
|
// @ID get-entitlements
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Enterprise
|
|
// @Success 200 {object} codersdk.Entitlements
|
|
// @Router /entitlements [get]
|
|
func (api *API) serveEntitlements(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
api.entitlementsMu.RLock()
|
|
entitlements := api.entitlements
|
|
api.entitlementsMu.RUnlock()
|
|
httpapi.Write(ctx, rw, http.StatusOK, entitlements)
|
|
}
|
|
|
|
func (api *API) runEntitlementsLoop(ctx context.Context) {
|
|
eb := backoff.NewExponentialBackOff()
|
|
eb.MaxElapsedTime = 0 // retry indefinitely
|
|
b := backoff.WithContext(eb, ctx)
|
|
updates := make(chan struct{}, 1)
|
|
subscribed := false
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
// pass
|
|
}
|
|
if !subscribed {
|
|
cancel, err := api.Pubsub.Subscribe(PubsubEventLicenses, func(_ context.Context, _ []byte) {
|
|
// don't block. If the channel is full, drop the event, as there is a resync
|
|
// scheduled already.
|
|
select {
|
|
case updates <- struct{}{}:
|
|
// pass
|
|
default:
|
|
// pass
|
|
}
|
|
})
|
|
if err != nil {
|
|
api.Logger.Warn(ctx, "failed to subscribe to license updates", slog.Error(err))
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(b.NextBackOff()):
|
|
}
|
|
continue
|
|
}
|
|
// nolint: revive
|
|
defer cancel()
|
|
subscribed = true
|
|
api.Logger.Debug(ctx, "successfully subscribed to pubsub")
|
|
}
|
|
|
|
api.Logger.Debug(ctx, "syncing licensed entitlements")
|
|
err := api.updateEntitlements(ctx)
|
|
if err != nil {
|
|
api.Logger.Warn(ctx, "failed to get feature entitlements", slog.Error(err))
|
|
time.Sleep(b.NextBackOff())
|
|
continue
|
|
}
|
|
b.Reset()
|
|
api.Logger.Debug(ctx, "synced licensed entitlements")
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(api.EntitlementsUpdateInterval):
|
|
continue
|
|
case <-updates:
|
|
api.Logger.Debug(ctx, "got pubsub update")
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
func (api *API) Authorize(r *http.Request, action rbac.Action, object rbac.Objecter) bool {
|
|
return api.AGPL.HTTPAuth.Authorize(r, action, object)
|
|
}
|