mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
Add Service Banners (#5272)
This commit is contained in:
@@ -127,6 +127,13 @@ func New(ctx context.Context, options *Options) (*API, error) {
|
||||
r.Get("/", api.workspaceQuota)
|
||||
})
|
||||
})
|
||||
r.Route("/service-banner", func(r chi.Router) {
|
||||
r.Use(
|
||||
apiKeyMiddleware,
|
||||
)
|
||||
r.Get("/", api.serviceBanner)
|
||||
r.Put("/", api.putServiceBanner)
|
||||
})
|
||||
})
|
||||
|
||||
if len(options.SCIMAPIKey) != 0 {
|
||||
|
||||
@@ -113,6 +113,7 @@ type LicenseOptions struct {
|
||||
HighAvailability bool
|
||||
MultipleGitAuth bool
|
||||
ExternalProvisionerDaemons bool
|
||||
ServiceBanners bool
|
||||
}
|
||||
|
||||
// AddLicense generates a new license with the options provided and inserts it.
|
||||
@@ -164,6 +165,11 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
|
||||
externalProvisionerDaemons = 1
|
||||
}
|
||||
|
||||
serviceBanners := int64(0)
|
||||
if options.ServiceBanners {
|
||||
serviceBanners = 1
|
||||
}
|
||||
|
||||
c := &license.Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: "test@testing.test",
|
||||
@@ -186,6 +192,7 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
|
||||
TemplateRBAC: rbacEnabled,
|
||||
MultipleGitAuth: multipleGitAuth,
|
||||
ExternalProvisionerDaemons: externalProvisionerDaemons,
|
||||
ServiceBanners: serviceBanners,
|
||||
},
|
||||
}
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c)
|
||||
|
||||
@@ -49,6 +49,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
|
||||
|
||||
skipRoutes, assertRoute := coderdtest.AGPLRoutes(a)
|
||||
skipRoutes["GET:/api/v2/organizations/{organization}/provisionerdaemons/serve"] = "This route checks for RBAC dependent on input parameters!"
|
||||
skipRoutes["GET:/api/v2/service-banner/"] = "This route is available to all users"
|
||||
|
||||
assertRoute["GET:/api/v2/entitlements"] = coderdtest.RouteCheck{
|
||||
NoAuthorize: true,
|
||||
|
||||
@@ -123,6 +123,12 @@ func Entitlements(
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
if claims.Features.ServiceBanners > 0 {
|
||||
entitlements.Features[codersdk.FeatureServiceBanners] = codersdk.Feature{
|
||||
Entitlement: entitlement,
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
if claims.AllFeatures {
|
||||
allFeatures = true
|
||||
}
|
||||
@@ -252,6 +258,7 @@ type Features struct {
|
||||
HighAvailability int64 `json:"high_availability"`
|
||||
MultipleGitAuth int64 `json:"multiple_git_auth"`
|
||||
ExternalProvisionerDaemons int64 `json:"external_provisioner_daemons"`
|
||||
ServiceBanners int64 `json:"service_banners"`
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
|
||||
@@ -27,6 +27,7 @@ func TestEntitlements(t *testing.T) {
|
||||
codersdk.FeatureTemplateRBAC: true,
|
||||
codersdk.FeatureMultipleGitAuth: true,
|
||||
codersdk.FeatureExternalProvisionerDaemons: true,
|
||||
codersdk.FeatureServiceBanners: true,
|
||||
}
|
||||
|
||||
t.Run("Defaults", func(t *testing.T) {
|
||||
@@ -70,6 +71,7 @@ func TestEntitlements(t *testing.T) {
|
||||
TemplateRBAC: true,
|
||||
MultipleGitAuth: true,
|
||||
ExternalProvisionerDaemons: true,
|
||||
ServiceBanners: true,
|
||||
}),
|
||||
Exp: time.Now().Add(time.Hour),
|
||||
})
|
||||
@@ -78,7 +80,7 @@ func TestEntitlements(t *testing.T) {
|
||||
require.True(t, entitlements.HasLicense)
|
||||
require.False(t, entitlements.Trial)
|
||||
for _, featureName := range codersdk.FeatureNames {
|
||||
require.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[featureName].Entitlement)
|
||||
require.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[featureName].Entitlement, featureName)
|
||||
}
|
||||
})
|
||||
t.Run("SingleLicenseGrace", func(t *testing.T) {
|
||||
@@ -93,6 +95,7 @@ func TestEntitlements(t *testing.T) {
|
||||
HighAvailability: true,
|
||||
TemplateRBAC: true,
|
||||
ExternalProvisionerDaemons: true,
|
||||
ServiceBanners: true,
|
||||
GraceAt: time.Now().Add(-time.Hour),
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}),
|
||||
|
||||
@@ -109,6 +109,7 @@ func TestGetLicense(t *testing.T) {
|
||||
codersdk.FeatureTemplateRBAC: json.Number("1"),
|
||||
codersdk.FeatureMultipleGitAuth: json.Number("0"),
|
||||
codersdk.FeatureExternalProvisionerDaemons: json.Number("0"),
|
||||
codersdk.FeatureServiceBanners: json.Number("0"),
|
||||
}, licenses[0].Claims["features"])
|
||||
assert.Equal(t, int32(2), licenses[1].ID)
|
||||
assert.Equal(t, "testing2", licenses[1].Claims["account_id"])
|
||||
@@ -122,6 +123,7 @@ func TestGetLicense(t *testing.T) {
|
||||
codersdk.FeatureTemplateRBAC: json.Number("0"),
|
||||
codersdk.FeatureMultipleGitAuth: json.Number("0"),
|
||||
codersdk.FeatureExternalProvisionerDaemons: json.Number("0"),
|
||||
codersdk.FeatureServiceBanners: json.Number("0"),
|
||||
}, licenses[1].Claims["features"])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func (api *API) serviceBanner(rw http.ResponseWriter, r *http.Request) {
|
||||
api.entitlementsMu.RLock()
|
||||
isEntitled := api.entitlements.Features[codersdk.FeatureServiceBanners].Entitlement == codersdk.EntitlementEntitled
|
||||
api.entitlementsMu.RUnlock()
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
if !isEntitled {
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ServiceBanner{
|
||||
Enabled: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
serviceBannerJSON, err := api.Database.GetServiceBanner(r.Context())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ServiceBanner{
|
||||
Enabled: false,
|
||||
})
|
||||
return
|
||||
} else if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: fmt.Sprintf("database error: %+v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var serviceBanner codersdk.ServiceBanner
|
||||
err = json.Unmarshal([]byte(serviceBannerJSON), &serviceBanner)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: fmt.Sprintf(
|
||||
"unmarshal json: %+v, raw: %s", err, serviceBannerJSON,
|
||||
),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, serviceBanner)
|
||||
}
|
||||
|
||||
func validateHexColor(color string) error {
|
||||
if len(color) != 7 {
|
||||
return xerrors.New("expected 7 characters")
|
||||
}
|
||||
if color[0] != '#' {
|
||||
return xerrors.New("no # prefix")
|
||||
}
|
||||
_, err := hex.DecodeString(color[1:])
|
||||
return err
|
||||
}
|
||||
|
||||
func (api *API) putServiceBanner(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
if !api.Authorize(r, rbac.ActionUpdate, rbac.ResourceDeploymentConfig) {
|
||||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: "Insufficient permissions to update service banner",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var serviceBanner codersdk.ServiceBanner
|
||||
if !httpapi.Read(ctx, rw, r, &serviceBanner) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateHexColor(serviceBanner.BackgroundColor); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("parse color: %+v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
serviceBannerJSON, err := json.Marshal(serviceBanner)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("marshal banner: %+v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = api.Database.InsertOrUpdateServiceBanner(ctx, string(serviceBannerJSON))
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: fmt.Sprintf("database error: %+v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, serviceBanner)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package coderd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/enterprise/coderd/coderdenttest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestServiceBanners(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
adminClient := coderdenttest.New(t, &coderdenttest.Options{})
|
||||
|
||||
adminUser := coderdtest.CreateFirstUser(t, adminClient)
|
||||
|
||||
// Even without a license, the banner should return as disabled.
|
||||
sb, err := adminClient.ServiceBanner(ctx)
|
||||
require.NoError(t, err)
|
||||
require.False(t, sb.Enabled)
|
||||
|
||||
coderdenttest.AddLicense(t, adminClient, coderdenttest.LicenseOptions{
|
||||
ServiceBanners: true,
|
||||
})
|
||||
|
||||
// Default state
|
||||
sb, err = adminClient.ServiceBanner(ctx)
|
||||
require.NoError(t, err)
|
||||
require.False(t, sb.Enabled)
|
||||
|
||||
basicUserClient := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID)
|
||||
|
||||
// Regular user should be unable to set the banner
|
||||
sb.Enabled = true
|
||||
err = basicUserClient.SetServiceBanner(ctx, sb)
|
||||
require.Error(t, err)
|
||||
var sdkError *codersdk.Error
|
||||
require.True(t, errors.As(err, &sdkError))
|
||||
require.Equal(t, http.StatusForbidden, sdkError.StatusCode())
|
||||
|
||||
// But an admin can
|
||||
wantBanner := sb
|
||||
wantBanner.Enabled = true
|
||||
wantBanner.Message = "Hey"
|
||||
wantBanner.BackgroundColor = "#00FF00"
|
||||
err = adminClient.SetServiceBanner(ctx, wantBanner)
|
||||
require.NoError(t, err)
|
||||
gotBanner, err := adminClient.ServiceBanner(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, wantBanner, gotBanner)
|
||||
|
||||
// But even an admin can't give a bad color
|
||||
wantBanner.BackgroundColor = "#bad color"
|
||||
err = adminClient.SetServiceBanner(ctx, wantBanner)
|
||||
require.Error(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user