Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/pipecd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ func (s *server) run(ctx context.Context, input cli.Input) error {
verifier = apikeyverifier.NewVerifier(
ctx,
datastore.NewAPIKeyStore(ds),
datastore.NewProjectStore(ds),
apiKeyLastUsedCache,
input.Logger,
)
Expand Down
54 changes: 54 additions & 0 deletions pkg/app/ops/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ type projectStore interface {
List(ctx context.Context, opts datastore.ListOptions) ([]model.Project, error)
Get(ctx context.Context, id string) (*model.Project, error)
UpdateProjectStaticAdmin(ctx context.Context, id, username, password string) error
EnableProject(ctx context.Context, id string) error
DisableProject(ctx context.Context, id string) error
}

type Handler struct {
Expand Down Expand Up @@ -77,6 +79,7 @@ func NewHandler(port int, ps projectStore, sharedSSOConfigs []config.SharedSSOCo
mux.HandleFunc("/projects", h.handleListProjects)
mux.HandleFunc("/projects/add", h.handleAddProject)
mux.HandleFunc("/projects/reset-password", h.handleResetPassword)
mux.HandleFunc("/projects/toggle-status", h.handleToggleProjectStatus)

return h
}
Expand Down Expand Up @@ -150,6 +153,7 @@ func (h *Handler) handleListProjects(w http.ResponseWriter, r *http.Request) {
"StaticAdminDisabled": strconv.FormatBool(projects[i].StaticAdminDisabled),
"SharedSSOName": projects[i].SharedSsoName,
"CreatedAt": time.Unix(projects[i].CreatedAt, 0).String(),
"Disabled": strconv.FormatBool(projects[i].Disabled),
})
}
if err := listProjectsTmpl.Execute(w, data); err != nil {
Expand Down Expand Up @@ -337,3 +341,53 @@ func (h *Handler) handleAddProject(w http.ResponseWriter, r *http.Request) {
h.logger.Error("failed to render AddedProject page template", zap.Error(err))
}
}

func (h *Handler) handleToggleProjectStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "not found", http.StatusNotFound)
return
}

id := html.EscapeString(r.URL.Query().Get("ID"))
if id == "" {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}

action := html.EscapeString(r.URL.Query().Get("action"))
if action != "enable" && action != "disable" {
http.Error(w, "invalid action, must be 'enable' or 'disable'", http.StatusBadRequest)
return
}

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

var err error
if action == "enable" {
err = h.projectStore.EnableProject(ctx, id)
if err != nil {
h.logger.Error("failed to enable project",
zap.String("id", id),
zap.Error(err),
)
http.Error(w, fmt.Sprintf("Unable to enable project (%v)", err), http.StatusInternalServerError)
return
}
h.logger.Info("successfully enabled project", zap.String("id", id))
} else {
err = h.projectStore.DisableProject(ctx, id)
if err != nil {
h.logger.Error("failed to disable project",
zap.String("id", id),
zap.Error(err),
)
http.Error(w, fmt.Sprintf("Unable to disable project (%v)", err), http.StatusInternalServerError)
return
}
h.logger.Info("successfully disabled project", zap.String("id", id))
}

// Redirect back to the projects list
http.Redirect(w, r, "/projects", http.StatusSeeOther)
}
14 changes: 13 additions & 1 deletion pkg/app/ops/handler/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,25 @@ import (
"github.com/pipe-cd/pipecd/pkg/model"
)

type mockProjectStoreWrapper struct {
*datastoretest.MockProjectStore
}

func (m *mockProjectStoreWrapper) EnableProject(ctx context.Context, id string) error {
return nil
}

func (m *mockProjectStoreWrapper) DisableProject(ctx context.Context, id string) error {
return nil
}

func createMockHandler(ctrl *gomock.Controller) (*datastoretest.MockProjectStore, *Handler) {
m := datastoretest.NewMockProjectStore(ctrl)
logger, _ := zap.NewProduction()

h := NewHandler(
10101,
m,
&mockProjectStoreWrapper{MockProjectStore: m},
[]config.SharedSSOConfig{},
0,
logger,
Expand Down
10 changes: 10 additions & 0 deletions pkg/app/ops/handler/templates/ListProjects
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@
<th>Description</th>
<th>Static Admin Disabled</th>
<th>Shared SSO Name</th>
<th>Project Status</th>
<th>Created At</th>
<th>Reset Static Admin Password</th>
<th>Toggle Status</th>
</tr>
{{ range $index, $project := . }}
<tr>
Expand All @@ -45,8 +47,16 @@
<td>{{ $project.Description }}</td>
<td>{{ $project.StaticAdminDisabled }}</td>
<td>{{ $project.SharedSSOName }}</td>
<td>{{ if eq $project.Disabled "true" }}<strong style="color: red;">Disabled</strong>{{ else }}<strong style="color: green;">Enabled</strong>{{ end }}</td>
<td>{{ $project.CreatedAt }}</td>
<td><a href="/projects/reset-password?ID={{ $project.ID }}">Reset Password</a></td>
<td>
{{ if eq $project.Disabled "true" }}
<a href="/projects/toggle-status?ID={{ $project.ID }}&action=enable">Enable</a>
{{ else }}
<a href="/projects/toggle-status?ID={{ $project.ID }}&action=disable">Disable</a>
{{ end }}
</td>
</tr>
{{ end }}

Expand Down
41 changes: 39 additions & 2 deletions pkg/app/server/apikeyverifier/verifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,22 +30,30 @@ type apiKeyGetter interface {
Get(ctx context.Context, id string) (*model.APIKey, error)
}

type projectGetter interface {
Get(ctx context.Context, id string) (*model.Project, error)
}

type apiKeyLastUsedPutter interface {
Put(k string, v interface{}) error
}

type Verifier struct {
apiKeyCache cache.Cache
apiKeyStore apiKeyGetter
projectStore projectGetter
projectCache cache.Cache
apiKeyLastUsedCache apiKeyLastUsedPutter
logger *zap.Logger
nowFunc func() time.Time
}

func NewVerifier(ctx context.Context, getter apiKeyGetter, akluc apiKeyLastUsedPutter, logger *zap.Logger) *Verifier {
func NewVerifier(ctx context.Context, getter apiKeyGetter, projectGetter projectGetter, akluc apiKeyLastUsedPutter, logger *zap.Logger) *Verifier {
return &Verifier{
apiKeyCache: memorycache.NewTTLCache(ctx, 5*time.Minute, time.Minute),
apiKeyStore: getter,
projectStore: projectGetter,
projectCache: memorycache.NewTTLCache(ctx, 12*time.Hour, time.Hour),
apiKeyLastUsedCache: akluc,
logger: logger,
nowFunc: time.Now,
Expand Down Expand Up @@ -86,10 +94,13 @@ func (v *Verifier) Verify(ctx context.Context, key string) (*model.APIKey, error
return apiKey, nil
}

func (v *Verifier) checkAPIKey(_ context.Context, apiKey *model.APIKey, id, key string) error {
func (v *Verifier) checkAPIKey(ctx context.Context, apiKey *model.APIKey, id, key string) error {
if apiKey.Disabled {
return fmt.Errorf("the api key %s was already disabled", id)
}
if err := v.ensureProjectEnabled(ctx, apiKey.ProjectId); err != nil {
return err
}

if err := apiKey.CompareKey(key); err != nil {
return fmt.Errorf("invalid api key %s: %w", id, err)
Expand All @@ -101,3 +112,29 @@ func (v *Verifier) checkAPIKey(_ context.Context, apiKey *model.APIKey, id, key

return nil
}

func (v *Verifier) ensureProjectEnabled(ctx context.Context, projectID string) error {
if projectID == "" {
return fmt.Errorf("missing project id for api key")
}
if val, err := v.projectCache.Get(projectID); err == nil {
if enabled, ok := val.(bool); ok {
if enabled {
return nil
}
return fmt.Errorf("project %s is disabled", projectID)
}
}
proj, err := v.projectStore.Get(ctx, projectID)
if err != nil {
return fmt.Errorf("unable to find project %s: %w", projectID, err)
}
if proj.Disabled {
_ = v.projectCache.Put(projectID, false)
return fmt.Errorf("project %s is disabled", projectID)
}
if err := v.projectCache.Put(projectID, true); err != nil {
v.logger.Warn("unable to store project status in memory cache", zap.Error(err))
}
return nil
}
51 changes: 48 additions & 3 deletions pkg/app/server/apikeyverifier/verifier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ func (f *fakeRedisHashCache) Put(k string, v interface{}) error {
return nil
}

type fakeProjectGetter struct {
calls int
projects map[string]*model.Project
}

func (g *fakeProjectGetter) Get(_ context.Context, id string) (*model.Project, error) {
g.calls++
p, ok := g.projects[id]
if ok {
msg := proto.Clone(p)
return msg.(*model.Project), nil
}
return nil, fmt.Errorf("not found")
}

func TestVerify(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
Expand All @@ -60,6 +75,10 @@ func TestVerify(t *testing.T) {
key2, hash2, err := model.GenerateAPIKey(id2)
require.NoError(t, err)

var id3 = "project-disabled-api-key"
key3, hash3, err := model.GenerateAPIKey(id3)
require.NoError(t, err)

apiKeyGetter := &fakeAPIKeyGetter{
apiKeys: map[string]*model.APIKey{
id1: {
Expand All @@ -75,10 +94,27 @@ func TestVerify(t *testing.T) {
ProjectId: "test-project",
Disabled: true,
},
id3: {
Id: id3,
Name: id3,
KeyHash: hash3,
ProjectId: "disabled-project",
},
},
}
fakeRedisHashCache := &fakeRedisHashCache{}
v := NewVerifier(ctx, apiKeyGetter, fakeRedisHashCache, zap.NewNop())
projectGetter := &fakeProjectGetter{
projects: map[string]*model.Project{
"test-project": {
Id: "test-project",
},
"disabled-project": {
Id: "disabled-project",
Disabled: true,
},
},
}
v := NewVerifier(ctx, apiKeyGetter, projectGetter, fakeRedisHashCache, zap.NewNop())

// Not found key.
notFoundKey, _, err := model.GenerateAPIKey("not-found-api-key")
Expand All @@ -96,17 +132,26 @@ func TestVerify(t *testing.T) {
require.NotNil(t, err)
assert.Equal(t, "the api key disabled-api-key was already disabled", err.Error())
require.Equal(t, 2, apiKeyGetter.calls)
require.Equal(t, 0, projectGetter.calls)

// Found key but project is disabled.
apiKey, err = v.Verify(ctx, key3)
require.Nil(t, apiKey)
require.NotNil(t, err)
assert.Equal(t, "project disabled-project is disabled", err.Error())
require.Equal(t, 3, apiKeyGetter.calls)
require.Equal(t, 1, projectGetter.calls)

// Found key but invalid secret.
apiKey, err = v.Verify(ctx, fmt.Sprintf("%s.invalidhash", id1))
require.Nil(t, apiKey)
require.NotNil(t, err)
assert.Equal(t, "invalid api key test-api-key: wrong api key test-api-key.invalidhash: crypto/bcrypt: hashedPassword is not the hash of the given password", err.Error())
require.Equal(t, 3, apiKeyGetter.calls)
require.Equal(t, 4, apiKeyGetter.calls)

// OK.
apiKey, err = v.Verify(ctx, key1)
assert.Equal(t, id1, apiKey.Name)
assert.Nil(t, err)
require.Equal(t, 3, apiKeyGetter.calls)
require.Equal(t, 4, apiKeyGetter.calls)
}
11 changes: 11 additions & 0 deletions pkg/app/server/grpcapi/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ type commandOutputGetter interface {
type API struct {
apiservice.UnimplementedAPIServiceServer

projectStore datastore.ProjectStore
applicationStore apiApplicationStore
deploymentStore apiDeploymentStore
pipedStore apiPipedStore
Expand Down Expand Up @@ -108,6 +109,7 @@ func NewAPI(
logger *zap.Logger,
) *API {
a := &API{
projectStore: datastore.NewProjectStore(ds),
applicationStore: datastore.NewApplicationStore(ds),
deploymentStore: datastore.NewDeploymentStore(ds),
pipedStore: datastore.NewPipedStore(ds),
Expand Down Expand Up @@ -191,6 +193,15 @@ func (a *API) SyncApplication(ctx context.Context, req *apiservice.SyncApplicati
return nil, status.Error(codes.InvalidArgument, "Requested application does not belong to your project")
}

// Check if the project is disabled.
project, err := a.projectStore.Get(ctx, app.ProjectId)
if err != nil {
return nil, gRPCStoreError(err, "get project")
}
if project.Disabled {
return nil, status.Error(codes.FailedPrecondition, "Cannot execute command: project is currently disabled. Please contact your administrator to enable the project.")
}

cmd := model.Command{
Id: uuid.New().String(),
PipedId: app.PipedId,
Expand Down
Loading