mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
feat: add keys to organization provision daemons (#14627)
This commit is contained in:
@ -331,6 +331,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
|
||||
)
|
||||
r.Get("/", api.provisionerKeys)
|
||||
r.Post("/", api.postProvisionerKey)
|
||||
r.Get("/daemons", api.provisionerKeyDaemons)
|
||||
r.Route("/{provisionerkey}", func(r chi.Router) {
|
||||
r.Use(
|
||||
httpmw.ExtractProvisionerKeyParam(options.Database),
|
||||
|
@ -82,56 +82,77 @@ type provisionerDaemonAuth struct {
|
||||
|
||||
// authorize returns mutated tags if the given HTTP request is authorized to access the provisioner daemon
|
||||
// protobuf API, and returns nil, err otherwise.
|
||||
func (p *provisionerDaemonAuth) authorize(r *http.Request, orgID uuid.UUID, tags map[string]string) (map[string]string, error) {
|
||||
func (p *provisionerDaemonAuth) authorize(r *http.Request, orgID uuid.UUID, tags map[string]string) (uuid.UUID, map[string]string, error) {
|
||||
ctx := r.Context()
|
||||
apiKey, apiKeyOK := httpmw.APIKeyOptional(r)
|
||||
pk, pkOK := httpmw.ProvisionerKeyAuthOptional(r)
|
||||
provAuth := httpmw.ProvisionerDaemonAuthenticated(r)
|
||||
if !provAuth && !apiKeyOK {
|
||||
return nil, xerrors.New("no API key or provisioner key provided")
|
||||
return uuid.Nil, nil, xerrors.New("no API key or provisioner key provided")
|
||||
}
|
||||
if apiKeyOK && pkOK {
|
||||
return nil, xerrors.New("Both API key and provisioner key authentication provided. Only one is allowed.")
|
||||
return uuid.Nil, nil, xerrors.New("Both API key and provisioner key authentication provided. Only one is allowed.")
|
||||
}
|
||||
|
||||
// Provisioner Key Auth
|
||||
if pkOK {
|
||||
if pk.OrganizationID != orgID {
|
||||
return nil, xerrors.New("provisioner key unauthorized")
|
||||
return uuid.Nil, nil, xerrors.New("provisioner key unauthorized")
|
||||
}
|
||||
if tags != nil && !maps.Equal(tags, map[string]string{}) {
|
||||
return nil, xerrors.New("tags are not allowed when using a provisioner key")
|
||||
return uuid.Nil, nil, xerrors.New("tags are not allowed when using a provisioner key")
|
||||
}
|
||||
|
||||
// If using provisioner key / PSK auth, the daemon is, by definition, scoped to the organization.
|
||||
// Use the provisioner key tags here.
|
||||
tags = provisionersdk.MutateTags(uuid.Nil, pk.Tags)
|
||||
return tags, nil
|
||||
return pk.ID, tags, nil
|
||||
}
|
||||
|
||||
// User Auth
|
||||
tags = provisionersdk.MutateTags(apiKey.UserID, tags)
|
||||
if tags[provisionersdk.TagScope] == provisionersdk.ScopeUser {
|
||||
// Any authenticated user can create provisioner daemons scoped
|
||||
// for jobs that they own,
|
||||
return tags, nil
|
||||
}
|
||||
ua := httpmw.UserAuthorization(r)
|
||||
err := p.authorizer.Authorize(ctx, ua, policy.ActionCreate, rbac.ResourceProvisionerDaemon.InOrg(orgID))
|
||||
if err != nil {
|
||||
if !provAuth {
|
||||
return nil, xerrors.New("user unauthorized")
|
||||
if apiKeyOK {
|
||||
userKey, err := uuid.Parse(codersdk.ProvisionerKeyIDUserAuth)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, xerrors.Errorf("parse user provisioner key id: %w", err)
|
||||
}
|
||||
|
||||
// Allow fallback to PSK auth if the user is not allowed to create provisioner daemons.
|
||||
// This is to preserve backwards compatibility with existing user provisioner daemons.
|
||||
// If using PSK auth, the daemon is, by definition, scoped to the organization.
|
||||
tags = provisionersdk.MutateTags(uuid.Nil, tags)
|
||||
return tags, nil
|
||||
tags = provisionersdk.MutateTags(apiKey.UserID, tags)
|
||||
if tags[provisionersdk.TagScope] == provisionersdk.ScopeUser {
|
||||
// Any authenticated user can create provisioner daemons scoped
|
||||
// for jobs that they own,
|
||||
return userKey, tags, nil
|
||||
}
|
||||
ua := httpmw.UserAuthorization(r)
|
||||
err = p.authorizer.Authorize(ctx, ua, policy.ActionCreate, rbac.ResourceProvisionerDaemon.InOrg(orgID))
|
||||
if err != nil {
|
||||
if !provAuth {
|
||||
return uuid.Nil, nil, xerrors.New("user unauthorized")
|
||||
}
|
||||
|
||||
pskKey, err := uuid.Parse(codersdk.ProvisionerKeyIDPSK)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, xerrors.Errorf("parse psk provisioner key id: %w", err)
|
||||
}
|
||||
|
||||
// Allow fallback to PSK auth if the user is not allowed to create provisioner daemons.
|
||||
// This is to preserve backwards compatibility with existing user provisioner daemons.
|
||||
// If using PSK auth, the daemon is, by definition, scoped to the organization.
|
||||
tags = provisionersdk.MutateTags(uuid.Nil, tags)
|
||||
|
||||
return pskKey, tags, nil
|
||||
}
|
||||
|
||||
return userKey, tags, nil
|
||||
}
|
||||
|
||||
// User is allowed to create provisioner daemons
|
||||
return tags, nil
|
||||
// PSK Auth
|
||||
pskKey, err := uuid.Parse(codersdk.ProvisionerKeyIDPSK)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, xerrors.Errorf("parse psk provisioner key id: %w", err)
|
||||
}
|
||||
|
||||
tags = provisionersdk.MutateTags(uuid.Nil, tags)
|
||||
return pskKey, tags, nil
|
||||
}
|
||||
|
||||
// Serves the provisioner daemon protobuf API over a WebSocket.
|
||||
@ -194,7 +215,7 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request)
|
||||
api.Logger.Warn(ctx, "unnamed provisioner daemon")
|
||||
}
|
||||
|
||||
tags, err := api.provisionerDaemonAuth.authorize(r, organization.ID, tags)
|
||||
keyID, tags, err := api.provisionerDaemonAuth.authorize(r, organization.ID, tags)
|
||||
if err != nil {
|
||||
api.Logger.Warn(ctx, "unauthorized provisioner daemon serve request", slog.F("tags", tags), slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusForbidden,
|
||||
@ -267,6 +288,7 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request)
|
||||
Version: versionHdrVal,
|
||||
APIVersion: apiVersion,
|
||||
OrganizationID: organization.ID,
|
||||
KeyID: keyID,
|
||||
})
|
||||
if err != nil {
|
||||
if !xerrors.Is(err, context.Canceled) {
|
||||
|
@ -743,6 +743,7 @@ func TestGetProvisionerDaemons(t *testing.T) {
|
||||
Options: &coderdtest.Options{
|
||||
DeploymentValues: dv,
|
||||
},
|
||||
ProvisionerDaemonPSK: "provisionersftw",
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureExternalProvisionerDaemons: 1,
|
||||
@ -752,6 +753,16 @@ func TestGetProvisionerDaemons(t *testing.T) {
|
||||
})
|
||||
org := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{})
|
||||
orgAdmin, _ := coderdtest.CreateAnotherUser(t, client, org.ID, rbac.ScopedRoleOrgAdmin(org.ID))
|
||||
|
||||
res, err := orgAdmin.CreateProvisionerKey(context.Background(), org.ID, codersdk.CreateProvisionerKeyRequest{
|
||||
Name: "my-key",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
keys, err := orgAdmin.ListProvisionerKeys(context.Background(), org.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, keys, 1)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
daemonName := testutil.MustRandString(t, 63)
|
||||
@ -762,17 +773,32 @@ func TestGetProvisionerDaemons(t *testing.T) {
|
||||
Provisioners: []codersdk.ProvisionerType{
|
||||
codersdk.ProvisionerTypeEcho,
|
||||
},
|
||||
Tags: map[string]string{},
|
||||
Tags: map[string]string{},
|
||||
ProvisionerKey: res.Key,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
srv.DRPCConn().Close()
|
||||
|
||||
daemons, err := orgAdmin.OrganizationProvisionerDaemons(ctx, org.ID)
|
||||
require.NoError(t, err)
|
||||
if assert.Len(t, daemons, 1) {
|
||||
assert.Equal(t, daemonName, daemons[0].Name)
|
||||
assert.Equal(t, buildinfo.Version(), daemons[0].Version)
|
||||
assert.Equal(t, proto.CurrentVersion.String(), daemons[0].APIVersion)
|
||||
}
|
||||
require.Len(t, daemons, 1)
|
||||
|
||||
assert.Equal(t, daemonName, daemons[0].Name)
|
||||
assert.Equal(t, buildinfo.Version(), daemons[0].Version)
|
||||
assert.Equal(t, proto.CurrentVersion.String(), daemons[0].APIVersion)
|
||||
assert.Equal(t, keys[0].ID, daemons[0].KeyID)
|
||||
|
||||
pkDaemons, err := orgAdmin.ListProvisionerKeyDaemons(ctx, org.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, pkDaemons, 1)
|
||||
require.Len(t, pkDaemons[0].Daemons, 1)
|
||||
assert.Equal(t, keys[0].ID, pkDaemons[0].Key.ID)
|
||||
assert.Equal(t, keys[0].Name, pkDaemons[0].Key.Name)
|
||||
|
||||
assert.Equal(t, daemonName, pkDaemons[0].Daemons[0].Name)
|
||||
assert.Equal(t, buildinfo.Version(), pkDaemons[0].Daemons[0].Version)
|
||||
assert.Equal(t, proto.CurrentVersion.String(), pkDaemons[0].Daemons[0].APIVersion)
|
||||
assert.Equal(t, keys[0].ID, pkDaemons[0].Daemons[0].KeyID)
|
||||
})
|
||||
}
|
||||
|
@ -3,10 +3,15 @@ package coderd
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/provisionerdserver"
|
||||
"github.com/coder/coder/v2/coderd/provisionerkey"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
@ -54,6 +59,21 @@ func (api *API) postProvisionerKey(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if slices.ContainsFunc(codersdk.ReservedProvisionerKeyNames(), func(s string) bool {
|
||||
return strings.EqualFold(req.Name, s)
|
||||
}) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("Name cannot be reserved name '%s'", req.Name),
|
||||
Validations: []codersdk.ValidationError{
|
||||
{
|
||||
Field: "name",
|
||||
Detail: fmt.Sprintf("Name cannot be reserved name '%s'", req.Name),
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
params, token, err := provisionerkey.New(organization.ID, req.Name, req.Tags)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
@ -89,7 +109,7 @@ func (api *API) provisionerKeys(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
organization := httpmw.OrganizationParam(r)
|
||||
|
||||
pks, err := api.Database.ListProvisionerKeysByOrganization(ctx, organization.ID)
|
||||
pks, err := api.Database.ListProvisionerKeysByOrganizationExcludeReserved(ctx, organization.ID)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
@ -98,6 +118,54 @@ func (api *API) provisionerKeys(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(ctx, rw, http.StatusOK, convertProvisionerKeys(pks))
|
||||
}
|
||||
|
||||
// @Summary List provisioner key daemons
|
||||
// @ID list-provisioner-key-daemons
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Enterprise
|
||||
// @Param organization path string true "Organization ID"
|
||||
// @Success 200 {object} []codersdk.ProvisionerKeyDaemons
|
||||
// @Router /organizations/{organization}/provisionerkeys/daemons [get]
|
||||
func (api *API) provisionerKeyDaemons(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
organization := httpmw.OrganizationParam(r)
|
||||
|
||||
pks, err := api.Database.ListProvisionerKeysByOrganization(ctx, organization.ID)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
sdkKeys := convertProvisionerKeys(pks)
|
||||
|
||||
daemons, err := api.Database.GetProvisionerDaemonsByOrganization(ctx, organization.ID)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
// provisionerdserver.DefaultHeartbeatInterval*3 matches the healthcheck report staleInterval.
|
||||
recentDaemons := db2sdk.RecentProvisionerDaemons(time.Now(), provisionerdserver.DefaultHeartbeatInterval*3, daemons)
|
||||
|
||||
pkDaemons := []codersdk.ProvisionerKeyDaemons{}
|
||||
for _, key := range sdkKeys {
|
||||
// currently we exclude user-auth from this list
|
||||
if key.ID.String() == codersdk.ProvisionerKeyIDUserAuth {
|
||||
continue
|
||||
}
|
||||
daemons := []codersdk.ProvisionerDaemon{}
|
||||
for _, daemon := range recentDaemons {
|
||||
if daemon.KeyID == key.ID {
|
||||
daemons = append(daemons, daemon)
|
||||
}
|
||||
}
|
||||
pkDaemons = append(pkDaemons, codersdk.ProvisionerKeyDaemons{
|
||||
Key: key,
|
||||
Daemons: daemons,
|
||||
})
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, pkDaemons)
|
||||
}
|
||||
|
||||
// @Summary Delete provisioner key
|
||||
// @ID delete-provisioner-key
|
||||
// @Security CoderSessionToken
|
||||
@ -108,24 +176,18 @@ func (api *API) provisionerKeys(rw http.ResponseWriter, r *http.Request) {
|
||||
// @Router /organizations/{organization}/provisionerkeys/{provisionerkey} [delete]
|
||||
func (api *API) deleteProvisionerKey(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
organization := httpmw.OrganizationParam(r)
|
||||
provisionerKey := httpmw.ProvisionerKeyParam(r)
|
||||
|
||||
pk, err := api.Database.GetProvisionerKeyByName(ctx, database.GetProvisionerKeyByNameParams{
|
||||
OrganizationID: organization.ID,
|
||||
Name: provisionerKey.Name,
|
||||
})
|
||||
if err != nil {
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.InternalServerError(rw, err)
|
||||
if provisionerKey.ID.String() == codersdk.ProvisionerKeyIDBuiltIn ||
|
||||
provisionerKey.ID.String() == codersdk.ProvisionerKeyIDUserAuth ||
|
||||
provisionerKey.ID.String() == codersdk.ProvisionerKeyIDPSK {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("Cannot delete reserved '%s' provisioner key", provisionerKey.Name),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = api.Database.DeleteProvisionerKey(ctx, pk.ID)
|
||||
err := api.Database.DeleteProvisionerKey(ctx, provisionerKey.ID)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
|
@ -64,6 +64,20 @@ func TestProvisionerKeys(t *testing.T) {
|
||||
err = outsideOrgAdmin.DeleteProvisionerKey(ctx, owner.OrganizationID, "key")
|
||||
require.ErrorContains(t, err, "Resource not found")
|
||||
|
||||
// org admin cannot create reserved provisioner keys
|
||||
_, err = orgAdmin.CreateProvisionerKey(ctx, owner.OrganizationID, codersdk.CreateProvisionerKeyRequest{
|
||||
Name: codersdk.ProvisionerKeyNameBuiltIn,
|
||||
})
|
||||
require.ErrorContains(t, err, "reserved")
|
||||
_, err = orgAdmin.CreateProvisionerKey(ctx, owner.OrganizationID, codersdk.CreateProvisionerKeyRequest{
|
||||
Name: codersdk.ProvisionerKeyNameUserAuth,
|
||||
})
|
||||
require.ErrorContains(t, err, "reserved")
|
||||
_, err = orgAdmin.CreateProvisionerKey(ctx, owner.OrganizationID, codersdk.CreateProvisionerKeyRequest{
|
||||
Name: codersdk.ProvisionerKeyNamePSK,
|
||||
})
|
||||
require.ErrorContains(t, err, "reserved")
|
||||
|
||||
// org admin can list provisioner keys and get an empty list
|
||||
keys, err := orgAdmin.ListProvisionerKeys(ctx, owner.OrganizationID)
|
||||
require.NoError(t, err, "org admin list provisioner keys")
|
||||
@ -111,4 +125,12 @@ func TestProvisionerKeys(t *testing.T) {
|
||||
// org admin cannot delete a provisioner key that doesn't exist
|
||||
err = orgAdmin.DeleteProvisionerKey(ctx, owner.OrganizationID, "key")
|
||||
require.ErrorContains(t, err, "Resource not found")
|
||||
|
||||
// org admin cannot delete reserved provisioner keys
|
||||
err = orgAdmin.DeleteProvisionerKey(ctx, owner.OrganizationID, codersdk.ProvisionerKeyNameBuiltIn)
|
||||
require.ErrorContains(t, err, "reserved")
|
||||
err = orgAdmin.DeleteProvisionerKey(ctx, owner.OrganizationID, codersdk.ProvisionerKeyNameUserAuth)
|
||||
require.ErrorContains(t, err, "reserved")
|
||||
err = orgAdmin.DeleteProvisionerKey(ctx, owner.OrganizationID, codersdk.ProvisionerKeyNamePSK)
|
||||
require.ErrorContains(t, err, "reserved")
|
||||
}
|
||||
|
Reference in New Issue
Block a user