chore: scope provisioner daemons to organizations

This commit is contained in:
Steven Masley 2024-02-27 11:11:44 -06:00
parent 7fab067307
commit 184193ef41
13 changed files with 188 additions and 27 deletions

View File

@ -1239,10 +1239,17 @@ func (api *API) CreateInMemoryProvisionerDaemon(dialCtx context.Context, name st
}
}()
// All in memory provisioners will be apart of the default org for now.
defaultOrg, err := api.Database.GetDefaultOrganization(dbauthz.AsSystemRestricted(dialCtx))
if err != nil {
return nil, xerrors.Errorf("unable to fetch default org for in memory provisioner: %w", err)
}
//nolint:gocritic // in-memory provisioners are owned by system
daemon, err := api.Database.UpsertProvisionerDaemon(dbauthz.AsSystemRestricted(dialCtx), database.UpsertProvisionerDaemonParams{
Name: name,
CreatedAt: dbtime.Now(),
Name: name,
OrganizationID: defaultOrg.ID,
CreatedAt: dbtime.Now(),
Provisioners: []database.ProvisionerType{
database.ProvisionerTypeEcho, database.ProvisionerTypeTerraform,
},

View File

@ -162,6 +162,9 @@ var (
DisplayName: "Provisioner Daemon",
Site: rbac.Permissions(map[string][]rbac.Action{
// TODO: Add ProvisionerJob resource type.
// Might want to reduce the org read scope to a specific org
// in the future.
rbac.ResourceOrganization.Type: {rbac.ActionRead},
rbac.ResourceFile.Type: {rbac.ActionRead},
rbac.ResourceSystem.Type: {rbac.WildcardSymbol},
rbac.ResourceTemplate.Type: {rbac.ActionRead, rbac.ActionUpdate},

View File

@ -609,7 +609,8 @@ CREATE TABLE provisioner_daemons (
tags jsonb DEFAULT '{}'::jsonb NOT NULL,
last_seen_at timestamp with time zone,
version text DEFAULT ''::text NOT NULL,
api_version text DEFAULT '1.0'::text NOT NULL
api_version text DEFAULT '1.0'::text NOT NULL,
organization_id uuid NOT NULL
);
COMMENT ON COLUMN provisioner_daemons.api_version IS 'The API version of the provisioner daemon';
@ -1682,6 +1683,9 @@ ALTER TABLE ONLY organization_members
ALTER TABLE ONLY parameter_schemas
ADD CONSTRAINT parameter_schemas_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE;
ALTER TABLE ONLY provisioner_daemons
ADD CONSTRAINT provisioner_daemons_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ALTER TABLE ONLY provisioner_job_logs
ADD CONSTRAINT provisioner_job_logs_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE;

View File

@ -23,6 +23,7 @@ const (
ForeignKeyOrganizationMembersOrganizationIDUUID ForeignKeyConstraint = "organization_members_organization_id_uuid_fkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_organization_id_uuid_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ForeignKeyOrganizationMembersUserIDUUID ForeignKeyConstraint = "organization_members_user_id_uuid_fkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyParameterSchemasJobID ForeignKeyConstraint = "parameter_schemas_job_id_fkey" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE;
ForeignKeyProvisionerDaemonsOrganizationID ForeignKeyConstraint = "provisioner_daemons_organization_id_fkey" // ALTER TABLE ONLY provisioner_daemons ADD CONSTRAINT provisioner_daemons_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ForeignKeyProvisionerJobLogsJobID ForeignKeyConstraint = "provisioner_job_logs_job_id_fkey" // ALTER TABLE ONLY provisioner_job_logs ADD CONSTRAINT provisioner_job_logs_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE;
ForeignKeyProvisionerJobsOrganizationID ForeignKeyConstraint = "provisioner_jobs_organization_id_fkey" // ALTER TABLE ONLY provisioner_jobs ADD CONSTRAINT provisioner_jobs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ForeignKeyTailnetAgentsCoordinatorID ForeignKeyConstraint = "tailnet_agents_coordinator_id_fkey" // ALTER TABLE ONLY tailnet_agents ADD CONSTRAINT tailnet_agents_coordinator_id_fkey FOREIGN KEY (coordinator_id) REFERENCES tailnet_coordinators(id) ON DELETE CASCADE;

View File

@ -0,0 +1,2 @@
ALTER TABLE provisioner_daemons
DROP COLUMN organization_id;

View File

@ -0,0 +1,14 @@
-- At the time of this migration, only 1 org is expected in a deployment.
-- In the future when multi-org is more common, there might be a use case
-- to allow a provisioner to be associated with multiple orgs.
ALTER TABLE provisioner_daemons
ADD COLUMN organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE;
UPDATE
provisioner_daemons
SET
-- Default to the first org
organization_id = (SELECT id FROM organizations ORDER BY organizations.created_at ASC LIMIT 1 );
ALTER TABLE provisioner_daemons
ALTER COLUMN organization_id SET NOT NULL;

View File

@ -1908,7 +1908,8 @@ type ProvisionerDaemon struct {
LastSeenAt sql.NullTime `db:"last_seen_at" json:"last_seen_at"`
Version string `db:"version" json:"version"`
// The API version of the provisioner daemon
APIVersion string `db:"api_version" json:"api_version"`
APIVersion string `db:"api_version" json:"api_version"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
}
type ProvisionerJob struct {

View File

@ -3686,7 +3686,7 @@ func (q *sqlQuerier) DeleteOldProvisionerDaemons(ctx context.Context) error {
const getProvisionerDaemons = `-- name: GetProvisionerDaemons :many
SELECT
id, created_at, name, provisioners, replica_id, tags, last_seen_at, version, api_version
id, created_at, name, provisioners, replica_id, tags, last_seen_at, version, api_version, organization_id
FROM
provisioner_daemons
`
@ -3710,6 +3710,7 @@ func (q *sqlQuerier) GetProvisionerDaemons(ctx context.Context) ([]ProvisionerDa
&i.LastSeenAt,
&i.Version,
&i.APIVersion,
&i.OrganizationID,
); err != nil {
return nil, err
}
@ -3754,6 +3755,7 @@ INSERT INTO
tags,
last_seen_at,
"version",
organization_id,
api_version
)
VALUES (
@ -3764,27 +3766,29 @@ VALUES (
$4,
$5,
$6,
$7
$7,
$8
) ON CONFLICT("name", LOWER(COALESCE(tags ->> 'owner'::text, ''::text))) DO UPDATE SET
provisioners = $3,
tags = $4,
last_seen_at = $5,
"version" = $6,
api_version = $7
api_version = $8
WHERE
-- Only ones with the same tags are allowed clobber
provisioner_daemons.tags <@ $4 :: jsonb
RETURNING id, created_at, name, provisioners, replica_id, tags, last_seen_at, version, api_version
RETURNING id, created_at, name, provisioners, replica_id, tags, last_seen_at, version, api_version, organization_id
`
type UpsertProvisionerDaemonParams struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
Name string `db:"name" json:"name"`
Provisioners []ProvisionerType `db:"provisioners" json:"provisioners"`
Tags StringMap `db:"tags" json:"tags"`
LastSeenAt sql.NullTime `db:"last_seen_at" json:"last_seen_at"`
Version string `db:"version" json:"version"`
APIVersion string `db:"api_version" json:"api_version"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
Name string `db:"name" json:"name"`
Provisioners []ProvisionerType `db:"provisioners" json:"provisioners"`
Tags StringMap `db:"tags" json:"tags"`
LastSeenAt sql.NullTime `db:"last_seen_at" json:"last_seen_at"`
Version string `db:"version" json:"version"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
APIVersion string `db:"api_version" json:"api_version"`
}
func (q *sqlQuerier) UpsertProvisionerDaemon(ctx context.Context, arg UpsertProvisionerDaemonParams) (ProvisionerDaemon, error) {
@ -3795,6 +3799,7 @@ func (q *sqlQuerier) UpsertProvisionerDaemon(ctx context.Context, arg UpsertProv
arg.Tags,
arg.LastSeenAt,
arg.Version,
arg.OrganizationID,
arg.APIVersion,
)
var i ProvisionerDaemon
@ -3808,6 +3813,7 @@ func (q *sqlQuerier) UpsertProvisionerDaemon(ctx context.Context, arg UpsertProv
&i.LastSeenAt,
&i.Version,
&i.APIVersion,
&i.OrganizationID,
)
return i, err
}

View File

@ -24,6 +24,7 @@ INSERT INTO
tags,
last_seen_at,
"version",
organization_id,
api_version
)
VALUES (
@ -34,6 +35,7 @@ VALUES (
@tags,
@last_seen_at,
@version,
@organization_id,
@api_version
) ON CONFLICT("name", LOWER(COALESCE(tags ->> 'owner'::text, ''::text))) DO UPDATE SET
provisioners = @provisioners,

View File

@ -64,3 +64,32 @@ func RequireAPIKeyOrWorkspaceAgent() func(http.Handler) http.Handler {
})
}
}
// RequireAPIKeyOrProvisionerDaemonAuth is middleware that should be inserted
// after optional ExtractAPIKey and ExtractProvisionerDaemonAuthenticated
// middlewares to ensure one of the two authentication methods is provided.
//
// If both are provided, an error is returned to avoid misuse.
func RequireAPIKeyOrProvisionerDaemonAuth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, hasAPIKey := APIKeyOptional(r)
hasProvisionerDaemon := ProvisionerDaemonAuthenticated(r)
if hasAPIKey && hasProvisionerDaemon {
httpapi.Write(r.Context(), w, http.StatusBadRequest, codersdk.Response{
Message: "API key and external provisioner authentication provided, but only one is allowed",
})
return
}
if !hasAPIKey && !hasProvisionerDaemon {
httpapi.Write(r.Context(), w, http.StatusUnauthorized, codersdk.Response{
Message: "API key or external provisioner authentication required, but none provided",
})
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@ -0,0 +1,84 @@
package httpmw
import (
"context"
"crypto/subtle"
"net/http"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
)
type provisionerDaemonContextKey struct{}
func ProvisionerDaemonAuthenticated(r *http.Request) bool {
proxy, ok := r.Context().Value(workspaceProxyContextKey{}).(bool)
return ok && proxy
}
type ExtractProvisionerAuthConfig struct {
DB database.Store
Optional bool
}
func ExtractProvisionerDaemonAuthenticated(opts ExtractProvisionerAuthConfig, psk string) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if psk == "" {
if opts.Optional {
next.ServeHTTP(w, r)
return
}
// No psk means external provisioner daemons are not allowed.
// So their auth is not valid.
httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{
Message: "External provisioner daemons not enabled",
})
return
}
token := r.Header.Get(codersdk.ProvisionerDaemonPSK)
if token == "" {
if opts.Optional {
next.ServeHTTP(w, r)
return
}
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
Message: "provisioner daemon auth token required",
})
return
}
if subtle.ConstantTimeCompare([]byte(token), []byte(psk)) != 1 {
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
Message: "provisioner daemon auth token invalid",
})
return
}
// The PSK does not indicate a specific provisioner daemon. So just
// store a boolean so the caller can check if the request is from an
// authenticated provisioner daemon.
ctx = context.WithValue(ctx, provisionerDaemonContextKey{}, true)
ctx = dbauthz.AsProvisionerd(ctx)
subj, ok := dbauthz.ActorFromContext(ctx)
if !ok {
// This should never happen
httpapi.InternalServerError(w, xerrors.New("developer error: ExtractProvisionerDaemonAuth missing rbac actor"))
}
// Use the same subject for the userAuthKey
ctx = context.WithValue(ctx, userAuthKey{}, Authorization{
Actor: subj,
ActorName: "provisioner_daemon",
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

View File

@ -292,6 +292,15 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
r.Route("/organizations/{organization}/provisionerdaemons", func(r chi.Router) {
r.Use(
api.provisionerDaemonsEnabledMW,
apiKeyMiddlewareOptional,
httpmw.ExtractProvisionerDaemonAuthenticated(httpmw.ExtractProvisionerAuthConfig{
DB: api.Database,
Optional: true,
}, api.ProvisionerDaemonPSK),
// Either a user auth or provisioner auth is required
// to move forward.
httpmw.RequireAPIKeyOrProvisionerDaemonAuth(),
httpmw.ExtractOrganizationParam(api.Database),
)
r.With(apiKeyMiddleware).Get("/", api.provisionerDaemons)
r.With(apiKeyMiddlewareOptional).Get("/serve", api.provisionerDaemonServe)

View File

@ -86,11 +86,8 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) {
})
return
}
apiDaemons := make([]codersdk.ProvisionerDaemon, 0)
for _, daemon := range daemons {
apiDaemons = append(apiDaemons, db2sdk.ProvisionerDaemon(daemon))
}
httpapi.Write(ctx, rw, http.StatusOK, apiDaemons)
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.List(daemons, db2sdk.ProvisionerDaemon))
}
type provisionerDaemonAuth struct {
@ -140,6 +137,7 @@ func (p *provisionerDaemonAuth) authorize(r *http.Request, tags map[string]strin
// @Router /organizations/{organization}/provisionerdaemons/serve [get]
func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
organization := httpmw.OrganizationParam(r)
tags := map[string]string{}
if r.URL.Query().Has("tag") {
@ -252,13 +250,14 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request)
// Create the daemon in the database.
now := dbtime.Now()
daemon, err := api.Database.UpsertProvisionerDaemon(authCtx, database.UpsertProvisionerDaemonParams{
Name: name,
Provisioners: provisioners,
Tags: tags,
CreatedAt: now,
LastSeenAt: sql.NullTime{Time: now, Valid: true},
Version: versionHdrVal,
APIVersion: apiVersion,
Name: name,
Provisioners: provisioners,
Tags: tags,
CreatedAt: now,
LastSeenAt: sql.NullTime{Time: now, Valid: true},
Version: versionHdrVal,
APIVersion: apiVersion,
OrganizationID: organization.ID,
})
if err != nil {
if !xerrors.Is(err, context.Canceled) {