From b39a93f135e091dc5f135109c5f0ad804f4d45fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Fri, 26 Feb 2021 18:04:36 +0100 Subject: [PATCH] Add support for resource flagging; system/application --- pkg/flag/flag.go | 288 +++++++++++++++ pkg/flag/types/type.go | 25 ++ store/flags.gen.go | 93 +++++ store/flags.yaml | 48 +++ store/interfaces.gen.go | 2 + store/rdbms/applications.go | 4 + store/rdbms/flags.gen.go | 417 ++++++++++++++++++++++ store/rdbms/flags.go | 26 ++ store/rdbms/rdbms_schema.go | 13 + store/tests/flags_test.go | 53 +++ store/tests/gen_test.go | 6 + system/rest.yaml | 51 +++ system/rest/application.go | 53 ++- system/rest/handlers/application.go | 38 ++ system/rest/request/application.go | 234 ++++++++++++ system/service/access_control.go | 12 + system/service/application.go | 41 +++ system/service/application_actions.gen.go | 104 ++++++ system/service/application_actions.yaml | 14 + system/types/applications.go | 27 ++ tests/system/application_test.go | 279 +++++++++++++++ 21 files changed, 1825 insertions(+), 3 deletions(-) create mode 100644 pkg/flag/flag.go create mode 100644 pkg/flag/types/type.go create mode 100644 store/flags.gen.go create mode 100644 store/flags.yaml create mode 100644 store/rdbms/flags.gen.go create mode 100644 store/rdbms/flags.go create mode 100644 store/tests/flags_test.go diff --git a/pkg/flag/flag.go b/pkg/flag/flag.go new file mode 100644 index 000000000..6ca160fa3 --- /dev/null +++ b/pkg/flag/flag.go @@ -0,0 +1,288 @@ +package flag + +import ( + "context" + "fmt" + + "github.com/cortezaproject/corteza-server/pkg/flag/types" + "github.com/cortezaproject/corteza-server/store" +) + +type ( + FlaggedResource interface { + GetFlags() []string + SetFlags([]string) + FlagResourceKind() string + FlagResourceID() uint64 + } +) + +// Search returns a slice of IDs corresponding to the filtered flags +func Search(ctx context.Context, s store.Storer, owner uint64, kind string, flags ...string) ([]uint64, error) { + rr := make([]uint64, 0, 100) + + ff, _, err := store.SearchFlags(ctx, s, types.FlagFilter{ + Kind: kind, + OwnedBy: []uint64{0, owner}, + Name: flags, + }) + + if err != nil { + return nil, err + } + + // Little helper to generate a map index for the fetched label resource + mix := func(resID uint64) string { + return fmt.Sprintf("%s:%d", kind, resID) + } + + // Firstly get all of the flags for the given user. + // Take note of inactive flags so we can filter them out of the global set + out := make(map[string]bool) + for _, f := range ff { + if f.OwnedBy != 0 { + if f.Active { + rr = append(rr, f.ResourceID) + } else { + out[mix(f.ResourceID)] = true + } + } + } + + // Go over global flags, exclude any ignored flags + for _, f := range ff { + if f.OwnedBy == 0 { + if f.Active && !out[mix(f.ResourceID)] { + rr = append(rr, f.ResourceID) + } + } + } + + return rr, nil +} + +// Create creates a new flag for the given resource +// +// If that flag for that owner for this resource already exists, it's skipped. +// Access control and any other validations should be performed by the caller. +func Create(ctx context.Context, s store.Storer, r FlaggedResource, ownedBy uint64, flag string) error { + // Try to preload existing own flag + own, err := store.LookupFlagByKindResourceIDOwnedByName(ctx, s, r.FlagResourceKind(), r.FlagResourceID(), ownedBy, flag) + if err != nil && err != store.ErrNotFound { + return err + } + + if own != nil && own.Active { + return fmt.Errorf("flag %s for resource %s %d already exists", flag, r.FlagResourceKind(), r.FlagResourceID()) + } + + // If we have an inactive flag, mark it as active + if own != nil && !own.Active { + own.Active = true + return store.UpdateFlag(ctx, s, own) + } + + own = &types.Flag{ + Kind: r.FlagResourceKind(), + ResourceID: r.FlagResourceID(), + OwnedBy: ownedBy, + Name: flag, + Active: true, + } + + return store.CreateFlag(ctx, s, own) +} + +// Delete removes the flag from this resource +// +// Access control and any other validations should be performed by the caller. +// +// This operation has two outcomes: +// * if we are removing a flag defined for a specifc user, it is deleted +// * if we are removing a flag defined globally (no owner), we create a new inactive flag +func Delete(ctx context.Context, s store.Storer, r FlaggedResource, ownedBy uint64, flag string) error { + var ( + own *types.Flag + global *types.Flag + err error + ) + + // Try to find the global flag + global, err = store.LookupFlagByKindResourceIDOwnedByName(ctx, s, r.FlagResourceKind(), r.FlagResourceID(), 0, flag) + if err != nil && err != store.ErrNotFound { + return err + } + + // Try to find own flag + own, err = store.LookupFlagByKindResourceIDOwnedByName(ctx, s, r.FlagResourceKind(), r.FlagResourceID(), ownedBy, flag) + if err != nil && err != store.ErrNotFound { + return err + } + + if own == nil && global == nil { + return fmt.Errorf("flag not found for resource %s %d", r.FlagResourceKind(), r.FlagResourceID()) + } + + // If we're deleting global flag, do it + if ownedBy == 0 { + if global == nil { + return fmt.Errorf("global flag not found for %s %d", r.FlagResourceKind(), r.FlagResourceID()) + } + + return store.DeleteFlag(ctx, s, global) + } + + // If we're deleting own flag and there is no global flag, delete own flag + if own != nil && global == nil { + return store.DeleteFlag(ctx, s, own) + } + + // If we're deleting own flag and there is a global flag, mark own flag as inactive + if own != nil && global != nil { + own.Active = false + return store.UpdateFlag(ctx, s, own) + } + + // This can't happen, but just to be safe + return fmt.Errorf("invalid flag removal state") +} + +// Load updates the provided resources with storreed flags +// +// 1. All global flags for this resource are fetched +// 2. All user-specifc flags for this resource are fetched, overwriting global flags +func Load(ctx context.Context, s store.Storer, incFlags uint, userID uint64, rr ...FlaggedResource) error { + for _, r := range rr { + + var ( + flags []string + err error + ) + + if incFlags == 0 { + flags, err = loadCalculated(ctx, s, userID, r) + } else if incFlags == 1 { + flags, err = loadGlobal(ctx, s, userID, r) + } else if incFlags == 2 { + flags, err = loadOwn(ctx, s, userID, r) + } else { + return fmt.Errorf("unknown flag inclusion: %d", incFlags) + } + if err != nil { + return err + } + + r.SetFlags(flags) + } + return nil +} + +func loadCalculated(ctx context.Context, s store.Storer, userID uint64, r FlaggedResource) ([]string, error) { + var ( + ff types.FlagSet + err error + + fMap = make(map[string]bool) + ) + + // Get flags for all users + ff, _, err = store.SearchFlags(ctx, s, types.FlagFilter{ + Kind: r.FlagResourceKind(), + ResourceID: []uint64{r.FlagResourceID()}, + OwnedBy: []uint64{0}, + }) + if err != nil { + return nil, err + } + for _, f := range ff { + fMap[f.Name] = f.Active + } + + // Get flags for the given user & merge with general flags + ff, _, err = store.SearchFlags(ctx, s, types.FlagFilter{ + Kind: r.FlagResourceKind(), + ResourceID: []uint64{r.FlagResourceID()}, + OwnedBy: []uint64{userID}, + }) + if err != nil { + return nil, err + } + + for _, f := range ff { + fMap[f.Name] = f.Active + } + + // convert to a slice + rr := make([]string, 0, len(fMap)) + for k, v := range fMap { + if v { + rr = append(rr, k) + } + } + + return rr, nil +} + +func loadGlobal(ctx context.Context, s store.Storer, userID uint64, r FlaggedResource) ([]string, error) { + var ( + ff types.FlagSet + err error + + fMap = make(map[string]bool) + ) + + // Get flags for all users + ff, _, err = store.SearchFlags(ctx, s, types.FlagFilter{ + Kind: r.FlagResourceKind(), + ResourceID: []uint64{r.FlagResourceID()}, + OwnedBy: []uint64{0}, + }) + if err != nil { + return nil, err + } + for _, f := range ff { + fMap[f.Name] = f.Active + } + + // convert to a slice + rr := make([]string, 0, len(fMap)) + for _, f := range ff { + if f.Active { + rr = append(rr, f.Name) + } + } + + return rr, nil +} + +func loadOwn(ctx context.Context, s store.Storer, userID uint64, r FlaggedResource) ([]string, error) { + var ( + ff types.FlagSet + err error + + fMap = make(map[string]bool) + ) + + // Get flags for all users + ff, _, err = store.SearchFlags(ctx, s, types.FlagFilter{ + Kind: r.FlagResourceKind(), + ResourceID: []uint64{r.FlagResourceID()}, + OwnedBy: []uint64{userID}, + }) + if err != nil { + return nil, err + } + for _, f := range ff { + fMap[f.Name] = f.Active + } + + // convert to a slice + rr := make([]string, 0, len(fMap)) + for _, f := range ff { + if f.Active { + rr = append(rr, f.Name) + } + } + + return rr, nil +} diff --git a/pkg/flag/types/type.go b/pkg/flag/types/type.go new file mode 100644 index 000000000..65c8433d8 --- /dev/null +++ b/pkg/flag/types/type.go @@ -0,0 +1,25 @@ +package types + +type ( + Flag struct { + // Kind of the flagged resource + Kind string + // ID if the flagged resource + ResourceID uint64 + // The owner of this flag; 0 = everyone + OwnedBy uint64 + + Name string + Active bool + } + + // @todo codegen this thing + FlagSet []*Flag + + FlagFilter struct { + Kind string + ResourceID []uint64 + OwnedBy []uint64 + Name []string + } +) diff --git a/store/flags.gen.go b/store/flags.gen.go new file mode 100644 index 000000000..b39d1c658 --- /dev/null +++ b/store/flags.gen.go @@ -0,0 +1,93 @@ +package store + +// This file is auto-generated. +// +// Template: pkg/codegen/assets/store_base.gen.go.tpl +// Definitions: store/flags.yaml +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +import ( + "context" + "github.com/cortezaproject/corteza-server/pkg/flag/types" +) + +type ( + Flags interface { + SearchFlags(ctx context.Context, f types.FlagFilter) (types.FlagSet, types.FlagFilter, error) + LookupFlagByKindResourceIDName(ctx context.Context, kind string, resource_id uint64, name string) (*types.Flag, error) + LookupFlagByKindResourceID(ctx context.Context, kind string, resource_id uint64) (*types.Flag, error) + LookupFlagByKindResourceIDOwnedBy(ctx context.Context, kind string, resource_id uint64, owned_by uint64) (*types.Flag, error) + LookupFlagByKindResourceIDOwnedByName(ctx context.Context, kind string, resource_id uint64, owned_by uint64, name string) (*types.Flag, error) + + CreateFlag(ctx context.Context, rr ...*types.Flag) error + + UpdateFlag(ctx context.Context, rr ...*types.Flag) error + + UpsertFlag(ctx context.Context, rr ...*types.Flag) error + + DeleteFlag(ctx context.Context, rr ...*types.Flag) error + DeleteFlagByKindResourceIDOwnedByName(ctx context.Context, kind string, resourceID uint64, ownedBy uint64, name string) error + + TruncateFlags(ctx context.Context) error + } +) + +var _ *types.Flag +var _ context.Context + +// SearchFlags returns all matching Flags from store +func SearchFlags(ctx context.Context, s Flags, f types.FlagFilter) (types.FlagSet, types.FlagFilter, error) { + return s.SearchFlags(ctx, f) +} + +// LookupFlagByKindResourceIDName Flag lookup by kind, resource, name +func LookupFlagByKindResourceIDName(ctx context.Context, s Flags, kind string, resource_id uint64, name string) (*types.Flag, error) { + return s.LookupFlagByKindResourceIDName(ctx, kind, resource_id, name) +} + +// LookupFlagByKindResourceID Flag lookup by kind, resource +func LookupFlagByKindResourceID(ctx context.Context, s Flags, kind string, resource_id uint64) (*types.Flag, error) { + return s.LookupFlagByKindResourceID(ctx, kind, resource_id) +} + +// LookupFlagByKindResourceIDOwnedBy Flag lookup by kind, resource, owner +func LookupFlagByKindResourceIDOwnedBy(ctx context.Context, s Flags, kind string, resource_id uint64, owned_by uint64) (*types.Flag, error) { + return s.LookupFlagByKindResourceIDOwnedBy(ctx, kind, resource_id, owned_by) +} + +// LookupFlagByKindResourceIDOwnedByName Flag lookup by kind, resource, owner, name +func LookupFlagByKindResourceIDOwnedByName(ctx context.Context, s Flags, kind string, resource_id uint64, owned_by uint64, name string) (*types.Flag, error) { + return s.LookupFlagByKindResourceIDOwnedByName(ctx, kind, resource_id, owned_by, name) +} + +// CreateFlag creates one or more Flags in store +func CreateFlag(ctx context.Context, s Flags, rr ...*types.Flag) error { + return s.CreateFlag(ctx, rr...) +} + +// UpdateFlag updates one or more (existing) Flags in store +func UpdateFlag(ctx context.Context, s Flags, rr ...*types.Flag) error { + return s.UpdateFlag(ctx, rr...) +} + +// UpsertFlag creates new or updates existing one or more Flags in store +func UpsertFlag(ctx context.Context, s Flags, rr ...*types.Flag) error { + return s.UpsertFlag(ctx, rr...) +} + +// DeleteFlag Deletes one or more Flags from store +func DeleteFlag(ctx context.Context, s Flags, rr ...*types.Flag) error { + return s.DeleteFlag(ctx, rr...) +} + +// DeleteFlagByKindResourceIDOwnedByName Deletes Flag from store +func DeleteFlagByKindResourceIDOwnedByName(ctx context.Context, s Flags, kind string, resourceID uint64, ownedBy uint64, name string) error { + return s.DeleteFlagByKindResourceIDOwnedByName(ctx, kind, resourceID, ownedBy, name) +} + +// TruncateFlags Deletes all Flags from store +func TruncateFlags(ctx context.Context, s Flags) error { + return s.TruncateFlags(ctx) +} diff --git a/store/flags.yaml b/store/flags.yaml new file mode 100644 index 000000000..b91b37114 --- /dev/null +++ b/store/flags.yaml @@ -0,0 +1,48 @@ +import: + - github.com/cortezaproject/corteza-server/pkg/flag/types + +types: + type: types.Flag + +fields: + - { field: Kind, isPrimaryKey: true } + - { field: ResourceID, isPrimaryKey: true } + - { field: OwnedBy, isPrimaryKey: true } + + - { field: Name, isPrimaryKey: true, lookupFilterPreprocessor: lower } + - { field: Active } + + +lookups: + - fields: [ Kind, ResourceID, Name ] + uniqueConstraintCheck: true + description: |- + Flag lookup by kind, resource, name + + - fields: [ Kind, ResourceID ] + uniqueConstraintCheck: true + description: |- + Flag lookup by kind, resource + + - fields: [ Kind, ResourceID, OwnedBy ] + uniqueConstraintCheck: true + description: |- + Flag lookup by kind, resource, owner + + - fields: [ Kind, ResourceID, OwnedBy, Name ] + uniqueConstraintCheck: true + description: |- + Flag lookup by kind, resource, owner, name + +search: + enablePaging: false + enableSorting: false + enableFilterCheckFunction: false + +upsert: + enable: true + +rdbms: + alias: flg + table: flags + customFilterConverter: true diff --git a/store/interfaces.gen.go b/store/interfaces.gen.go index 8bfedf558..265549470 100644 --- a/store/interfaces.gen.go +++ b/store/interfaces.gen.go @@ -25,6 +25,7 @@ package store // - store/federation_nodes.yaml // - store/federation_nodes_sync.yaml // - store/federation_shared_modules.yaml +// - store/flags.yaml // - store/labels.yaml // - store/messaging_attachments.yaml // - store/messaging_channel_members.yaml @@ -70,6 +71,7 @@ type ( FederationNodes FederationNodesSyncs FederationSharedModules + Flags Labels MessagingAttachments MessagingChannelMembers diff --git a/store/rdbms/applications.go b/store/rdbms/applications.go index 0f9f3ec20..bc56e55f4 100644 --- a/store/rdbms/applications.go +++ b/store/rdbms/applications.go @@ -18,6 +18,10 @@ func (s Store) convertApplicationFilter(f types.ApplicationFilter) (query squirr query = query.Where(squirrel.Eq{"app.id": f.LabeledIDs}) } + if len(f.FlaggedIDs) > 0 { + query = query.Where(squirrel.Eq{"app.id": f.FlaggedIDs}) + } + if f.Query != "" { qs := f.Query + "%" query = query.Where(squirrel.Or{ diff --git a/store/rdbms/flags.gen.go b/store/rdbms/flags.gen.go new file mode 100644 index 000000000..e44c659ea --- /dev/null +++ b/store/rdbms/flags.gen.go @@ -0,0 +1,417 @@ +package rdbms + +// This file is an auto-generated file +// +// Template: pkg/codegen/assets/store_rdbms.gen.go.tpl +// Definitions: store/flags.yaml +// +// Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated. + +import ( + "context" + "database/sql" + "github.com/Masterminds/squirrel" + "github.com/cortezaproject/corteza-server/pkg/errors" + "github.com/cortezaproject/corteza-server/pkg/flag/types" + "github.com/cortezaproject/corteza-server/store" +) + +var _ = errors.Is + +// SearchFlags returns all matching rows +// +// This function calls convertFlagFilter with the given +// types.FlagFilter and expects to receive a working squirrel.SelectBuilder +func (s Store) SearchFlags(ctx context.Context, f types.FlagFilter) (types.FlagSet, types.FlagFilter, error) { + var ( + err error + set []*types.Flag + q squirrel.SelectBuilder + ) + + return set, f, func() error { + q, err = s.convertFlagFilter(f) + if err != nil { + return err + } + + set, err = s.QueryFlags(ctx, q, nil) + return err + }() +} + +// QueryFlags queries the database, converts and checks each row and +// returns collected set +// +// Fn also returns total number of fetched items and last fetched item so that the caller can construct cursor +// for next page of results +func (s Store) QueryFlags( + ctx context.Context, + q squirrel.Sqlizer, + check func(*types.Flag) (bool, error), +) ([]*types.Flag, error) { + var ( + set = make([]*types.Flag, 0, DefaultSliceCapacity) + res *types.Flag + + // Query rows with + rows, err = s.Query(ctx, q) + ) + + if err != nil { + return nil, err + } + + defer rows.Close() + for rows.Next() { + if err = rows.Err(); err == nil { + res, err = s.internalFlagRowScanner(rows) + } + + if err != nil { + return nil, err + } + + set = append(set, res) + } + + return set, rows.Err() +} + +// LookupFlagByKindResourceIDName Flag lookup by kind, resource, name +func (s Store) LookupFlagByKindResourceIDName(ctx context.Context, kind string, resource_id uint64, name string) (*types.Flag, error) { + return s.execLookupFlag(ctx, squirrel.Eq{ + s.preprocessColumn("flg.kind", ""): store.PreprocessValue(kind, ""), + s.preprocessColumn("flg.rel_resource", ""): store.PreprocessValue(resource_id, ""), + s.preprocessColumn("flg.name", "lower"): store.PreprocessValue(name, "lower"), + }) +} + +// LookupFlagByKindResourceID Flag lookup by kind, resource +func (s Store) LookupFlagByKindResourceID(ctx context.Context, kind string, resource_id uint64) (*types.Flag, error) { + return s.execLookupFlag(ctx, squirrel.Eq{ + s.preprocessColumn("flg.kind", ""): store.PreprocessValue(kind, ""), + s.preprocessColumn("flg.rel_resource", ""): store.PreprocessValue(resource_id, ""), + }) +} + +// LookupFlagByKindResourceIDOwnedBy Flag lookup by kind, resource, owner +func (s Store) LookupFlagByKindResourceIDOwnedBy(ctx context.Context, kind string, resource_id uint64, owned_by uint64) (*types.Flag, error) { + return s.execLookupFlag(ctx, squirrel.Eq{ + s.preprocessColumn("flg.kind", ""): store.PreprocessValue(kind, ""), + s.preprocessColumn("flg.rel_resource", ""): store.PreprocessValue(resource_id, ""), + s.preprocessColumn("flg.owned_by", ""): store.PreprocessValue(owned_by, ""), + }) +} + +// LookupFlagByKindResourceIDOwnedByName Flag lookup by kind, resource, owner, name +func (s Store) LookupFlagByKindResourceIDOwnedByName(ctx context.Context, kind string, resource_id uint64, owned_by uint64, name string) (*types.Flag, error) { + return s.execLookupFlag(ctx, squirrel.Eq{ + s.preprocessColumn("flg.kind", ""): store.PreprocessValue(kind, ""), + s.preprocessColumn("flg.rel_resource", ""): store.PreprocessValue(resource_id, ""), + s.preprocessColumn("flg.owned_by", ""): store.PreprocessValue(owned_by, ""), + s.preprocessColumn("flg.name", "lower"): store.PreprocessValue(name, "lower"), + }) +} + +// CreateFlag creates one or more rows in flags table +func (s Store) CreateFlag(ctx context.Context, rr ...*types.Flag) (err error) { + for _, res := range rr { + err = s.checkFlagConstraints(ctx, res) + if err != nil { + return err + } + + err = s.execCreateFlags(ctx, s.internalFlagEncoder(res)) + if err != nil { + return err + } + } + + return +} + +// UpdateFlag updates one or more existing rows in flags +func (s Store) UpdateFlag(ctx context.Context, rr ...*types.Flag) error { + return s.partialFlagUpdate(ctx, nil, rr...) +} + +// partialFlagUpdate updates one or more existing rows in flags +func (s Store) partialFlagUpdate(ctx context.Context, onlyColumns []string, rr ...*types.Flag) (err error) { + for _, res := range rr { + err = s.checkFlagConstraints(ctx, res) + if err != nil { + return err + } + + err = s.execUpdateFlags( + ctx, + squirrel.Eq{ + s.preprocessColumn("flg.kind", ""): store.PreprocessValue(res.Kind, ""), s.preprocessColumn("flg.rel_resource", ""): store.PreprocessValue(res.ResourceID, ""), s.preprocessColumn("flg.owned_by", ""): store.PreprocessValue(res.OwnedBy, ""), s.preprocessColumn("flg.name", "lower"): store.PreprocessValue(res.Name, "lower"), + }, + s.internalFlagEncoder(res).Skip("kind", "rel_resource", "owned_by", "name").Only(onlyColumns...)) + if err != nil { + return err + } + } + + return +} + +// UpsertFlag updates one or more existing rows in flags +func (s Store) UpsertFlag(ctx context.Context, rr ...*types.Flag) (err error) { + for _, res := range rr { + err = s.checkFlagConstraints(ctx, res) + if err != nil { + return err + } + + err = s.execUpsertFlags(ctx, s.internalFlagEncoder(res)) + if err != nil { + return err + } + } + + return nil +} + +// DeleteFlag Deletes one or more rows from flags table +func (s Store) DeleteFlag(ctx context.Context, rr ...*types.Flag) (err error) { + for _, res := range rr { + + err = s.execDeleteFlags(ctx, squirrel.Eq{ + s.preprocessColumn("flg.kind", ""): store.PreprocessValue(res.Kind, ""), s.preprocessColumn("flg.rel_resource", ""): store.PreprocessValue(res.ResourceID, ""), s.preprocessColumn("flg.owned_by", ""): store.PreprocessValue(res.OwnedBy, ""), s.preprocessColumn("flg.name", "lower"): store.PreprocessValue(res.Name, "lower"), + }) + if err != nil { + return err + } + } + + return nil +} + +// DeleteFlagByKindResourceIDOwnedByName Deletes row from the flags table +func (s Store) DeleteFlagByKindResourceIDOwnedByName(ctx context.Context, kind string, resourceID uint64, ownedBy uint64, name string) error { + return s.execDeleteFlags(ctx, squirrel.Eq{ + s.preprocessColumn("flg.kind", ""): store.PreprocessValue(kind, ""), + s.preprocessColumn("flg.rel_resource", ""): store.PreprocessValue(resourceID, ""), + s.preprocessColumn("flg.owned_by", ""): store.PreprocessValue(ownedBy, ""), + s.preprocessColumn("flg.name", "lower"): store.PreprocessValue(name, "lower"), + }) +} + +// TruncateFlags Deletes all rows from the flags table +func (s Store) TruncateFlags(ctx context.Context) error { + return s.Truncate(ctx, s.flagTable()) +} + +// execLookupFlag prepares Flag query and executes it, +// returning types.Flag (or error) +func (s Store) execLookupFlag(ctx context.Context, cnd squirrel.Sqlizer) (res *types.Flag, err error) { + var ( + row rowScanner + ) + + row, err = s.QueryRow(ctx, s.flagsSelectBuilder().Where(cnd)) + if err != nil { + return + } + + res, err = s.internalFlagRowScanner(row) + if err != nil { + return + } + + return res, nil +} + +// execCreateFlags updates all matched (by cnd) rows in flags with given data +func (s Store) execCreateFlags(ctx context.Context, payload store.Payload) error { + return s.Exec(ctx, s.InsertBuilder(s.flagTable()).SetMap(payload)) +} + +// execUpdateFlags updates all matched (by cnd) rows in flags with given data +func (s Store) execUpdateFlags(ctx context.Context, cnd squirrel.Sqlizer, set store.Payload) error { + return s.Exec(ctx, s.UpdateBuilder(s.flagTable("flg")).Where(cnd).SetMap(set)) +} + +// execUpsertFlags inserts new or updates matching (by-primary-key) rows in flags with given data +func (s Store) execUpsertFlags(ctx context.Context, set store.Payload) error { + upsert, err := s.config.UpsertBuilder( + s.config, + s.flagTable(), + set, + s.preprocessColumn("kind", ""), + s.preprocessColumn("rel_resource", ""), + s.preprocessColumn("owned_by", ""), + s.preprocessColumn("name", "lower"), + ) + + if err != nil { + return err + } + + return s.Exec(ctx, upsert) +} + +// execDeleteFlags Deletes all matched (by cnd) rows in flags with given data +func (s Store) execDeleteFlags(ctx context.Context, cnd squirrel.Sqlizer) error { + return s.Exec(ctx, s.DeleteBuilder(s.flagTable("flg")).Where(cnd)) +} + +func (s Store) internalFlagRowScanner(row rowScanner) (res *types.Flag, err error) { + res = &types.Flag{} + + if _, has := s.config.RowScanners["flag"]; has { + scanner := s.config.RowScanners["flag"].(func(_ rowScanner, _ *types.Flag) error) + err = scanner(row, res) + } else { + err = row.Scan( + &res.Kind, + &res.ResourceID, + &res.OwnedBy, + &res.Name, + &res.Active, + ) + } + + if err == sql.ErrNoRows { + return nil, store.ErrNotFound.Stack(1) + } + + if err != nil { + return nil, errors.Store("could not scan flag db row").Wrap(err) + } else { + return res, nil + } +} + +// QueryFlags returns squirrel.SelectBuilder with set table and all columns +func (s Store) flagsSelectBuilder() squirrel.SelectBuilder { + return s.SelectBuilder(s.flagTable("flg"), s.flagColumns("flg")...) +} + +// flagTable name of the db table +func (Store) flagTable(aa ...string) string { + var alias string + if len(aa) > 0 { + alias = " AS " + aa[0] + } + + return "flags" + alias +} + +// FlagColumns returns all defined table columns +// +// With optional string arg, all columns are returned aliased +func (Store) flagColumns(aa ...string) []string { + var alias string + if len(aa) > 0 { + alias = aa[0] + "." + } + + return []string{ + alias + "kind", + alias + "rel_resource", + alias + "owned_by", + alias + "name", + alias + "active", + } +} + +// {true true false false false false} + +// internalFlagEncoder encodes fields from types.Flag to store.Payload (map) +// +// Encoding is done by using generic approach or by calling encodeFlag +// func when rdbms.customEncoder=true +func (s Store) internalFlagEncoder(res *types.Flag) store.Payload { + return store.Payload{ + "kind": res.Kind, + "rel_resource": res.ResourceID, + "owned_by": res.OwnedBy, + "name": res.Name, + "active": res.Active, + } +} + +// checkFlagConstraints performs lookups (on valid) resource to check if any of the values on unique fields +// already exists in the store +// +// Using built-in constraint checking would be more performant but unfortunately we can not rely +// on the full support (MySQL does not support conditional indexes) +func (s *Store) checkFlagConstraints(ctx context.Context, res *types.Flag) error { + // Consider resource valid when all fields in unique constraint check lookups + // have valid (non-empty) value + // + // Only string and uint64 are supported for now + // feel free to add additional types if needed + var valid = true + + valid = valid && len(res.Kind) > 0 + + valid = valid && res.ResourceID > 0 + + valid = valid && len(res.Name) > 0 + + valid = valid && len(res.Kind) > 0 + + valid = valid && res.ResourceID > 0 + + valid = valid && len(res.Kind) > 0 + + valid = valid && res.ResourceID > 0 + + valid = valid && res.OwnedBy > 0 + + valid = valid && len(res.Kind) > 0 + + valid = valid && res.ResourceID > 0 + + valid = valid && res.OwnedBy > 0 + + valid = valid && len(res.Name) > 0 + + if !valid { + return nil + } + + { + ex, err := s.LookupFlagByKindResourceIDName(ctx, res.Kind, res.ResourceID, res.Name) + if err == nil && ex != nil && ex.Kind != res.Kind && ex.ResourceID != res.ResourceID && ex.OwnedBy != res.OwnedBy && ex.Name != res.Name { + return store.ErrNotUnique.Stack(1) + } else if !errors.IsNotFound(err) { + return err + } + } + + { + ex, err := s.LookupFlagByKindResourceID(ctx, res.Kind, res.ResourceID) + if err == nil && ex != nil && ex.Kind != res.Kind && ex.ResourceID != res.ResourceID && ex.OwnedBy != res.OwnedBy && ex.Name != res.Name { + return store.ErrNotUnique.Stack(1) + } else if !errors.IsNotFound(err) { + return err + } + } + + { + ex, err := s.LookupFlagByKindResourceIDOwnedBy(ctx, res.Kind, res.ResourceID, res.OwnedBy) + if err == nil && ex != nil && ex.Kind != res.Kind && ex.ResourceID != res.ResourceID && ex.OwnedBy != res.OwnedBy && ex.Name != res.Name { + return store.ErrNotUnique.Stack(1) + } else if !errors.IsNotFound(err) { + return err + } + } + + { + ex, err := s.LookupFlagByKindResourceIDOwnedByName(ctx, res.Kind, res.ResourceID, res.OwnedBy, res.Name) + if err == nil && ex != nil && ex.Kind != res.Kind && ex.ResourceID != res.ResourceID && ex.OwnedBy != res.OwnedBy && ex.Name != res.Name { + return store.ErrNotUnique.Stack(1) + } else if !errors.IsNotFound(err) { + return err + } + } + + return nil +} diff --git a/store/rdbms/flags.go b/store/rdbms/flags.go new file mode 100644 index 000000000..2b92057f1 --- /dev/null +++ b/store/rdbms/flags.go @@ -0,0 +1,26 @@ +package rdbms + +import ( + "github.com/Masterminds/squirrel" + "github.com/cortezaproject/corteza-server/pkg/flag/types" +) + +func (s Store) convertFlagFilter(f types.FlagFilter) (query squirrel.SelectBuilder, err error) { + query = s.flagsSelectBuilder() + + query = query.Where(squirrel.Eq{"flg.kind": f.Kind}) + + if len(f.ResourceID) > 0 { + query = query.Where(squirrel.Eq{"flg.rel_resource": f.ResourceID}) + } + + if len(f.OwnedBy) > 0 { + query = query.Where(squirrel.Eq{"flg.owned_by": f.OwnedBy}) + } + + if len(f.Name) > 0 { + query = query.Where(squirrel.Eq{"flg.name": f.Name}) + } + + return +} diff --git a/store/rdbms/rdbms_schema.go b/store/rdbms/rdbms_schema.go index edccf4dea..24916cdda 100644 --- a/store/rdbms/rdbms_schema.go +++ b/store/rdbms/rdbms_schema.go @@ -62,6 +62,7 @@ func (s Schema) Tables() []*Table { s.RbacRules(), s.Settings(), s.Labels(), + s.Flags(), s.Templates(), s.ComposeAttachment(), s.ComposeChart(), @@ -317,6 +318,18 @@ func (Schema) Labels() *Table { ) } +func (Schema) Flags() *Table { + return TableDef("flags", + ColumnDef("kind", ColumnTypeVarchar, ColumnTypeLength(handleLength)), + ColumnDef("rel_resource", ColumnTypeIdentifier), + ColumnDef("owned_by", ColumnTypeIdentifier), + ColumnDef("name", ColumnTypeVarchar, ColumnTypeLength(resourceLength)), + ColumnDef("active", ColumnTypeBoolean), + + AddIndex("unique_kind_res_owner_name", IColumn("kind", "rel_resource", "owned_by"), IExpr("LOWER(name)")), + ) +} + func (Schema) Templates() *Table { return TableDef("templates", ID, diff --git a/store/tests/flags_test.go b/store/tests/flags_test.go new file mode 100644 index 000000000..6c9208a67 --- /dev/null +++ b/store/tests/flags_test.go @@ -0,0 +1,53 @@ +package tests + +import ( + "context" + "testing" + + "github.com/cortezaproject/corteza-server/pkg/flag/types" + "github.com/cortezaproject/corteza-server/store" + _ "github.com/joho/godotenv/autoload" + "github.com/stretchr/testify/require" +) + +func testFlags(t *testing.T, s store.Flags) { + var ( + ctx = context.Background() + ) + + t.Run("create", func(t *testing.T) { + req := require.New(t) + req.NoError(s.TruncateFlags(ctx)) + req.NoError(s.CreateFlag(ctx, &types.Flag{ + Kind: "kind", + ResourceID: 1, + OwnedBy: 2, + Name: "fname", + Active: true, + })) + }) + + t.Run("update", func(t *testing.T) { + req := require.New(t) + req.NoError(s.TruncateFlags(ctx)) + req.NoError(s.UpdateFlag(ctx, &types.Flag{ + Kind: "kind", + ResourceID: 1, + OwnedBy: 2, + Name: "fname", + Active: false, + })) + }) + + t.Run("upsert", func(t *testing.T) { + req := require.New(t) + req.NoError(s.TruncateFlags(ctx)) + req.NoError(s.UpsertFlag(ctx, &types.Flag{ + Kind: "kind", + ResourceID: 1, + OwnedBy: 2, + Name: "fname", + Active: true, + })) + }) +} diff --git a/store/tests/gen_test.go b/store/tests/gen_test.go index f20037a8b..e53aba939 100644 --- a/store/tests/gen_test.go +++ b/store/tests/gen_test.go @@ -23,6 +23,7 @@ package tests // - store/federation_nodes.yaml // - store/federation_nodes_sync.yaml // - store/federation_shared_modules.yaml +// - store/flags.yaml // - store/labels.yaml // - store/messaging_attachments.yaml // - store/messaging_channel_members.yaml @@ -156,6 +157,11 @@ func testAllGenerated(t *testing.T, s store.Storer) { testFederationSharedModules(t, s) }) + // Run generated tests for Flags + t.Run("Flags", func(t *testing.T) { + testFlags(t, s) + }) + // Run generated tests for Labels t.Run("Labels", func(t *testing.T) { testLabels(t, s) diff --git a/system/rest.yaml b/system/rest.yaml index da6755042..97c92a9ce 100644 --- a/system/rest.yaml +++ b/system/rest.yaml @@ -721,6 +721,13 @@ endpoints: name: labels title: Labels parser: label.ParseStrings + - name: flags + type: "[]string" + title: Flags + - name: incFlags + required: false + title: Calculated (0, default), global (1) or return only (2) own flags + type: uint - type: uint name: limit title: Limit @@ -795,6 +802,45 @@ endpoints: name: labels title: Labels parser: label.ParseStrings + + - name: flagCreate + method: POST + title: Flag application + path: "/{applicationID}/flag/{ownedBy}/{flag}" + parameters: + path: + - type: uint64 + name: applicationID + required: true + title: Application ID + - type: string + name: flag + required: true + title: Flag + - type: uint64 + name: ownedBy + required: false + title: Owner; 0 = everyone + + - name: flagDelete + method: DELETE + title: Unflag application + path: "/{applicationID}/flag/{ownedBy}/{flag}" + parameters: + path: + - type: uint64 + name: applicationID + required: true + title: Application ID + - type: string + name: flag + required: true + title: Flag + - type: uint64 + name: ownedBy + required: false + title: Owner; 0 = everyone + - name: read method: GET title: Read application details @@ -805,6 +851,11 @@ endpoints: name: applicationID required: true title: Application ID + get: + - name: incFlags + required: false + title: Calculated (0, default), global (1) or return only (2) own flags + type: uint - name: delete method: DELETE title: Remove application diff --git a/system/rest/application.go b/system/rest/application.go index 217872457..bd1405f55 100644 --- a/system/rest/application.go +++ b/system/rest/application.go @@ -5,8 +5,10 @@ import ( "strconv" "github.com/cortezaproject/corteza-server/pkg/api" + "github.com/cortezaproject/corteza-server/pkg/auth" "github.com/cortezaproject/corteza-server/pkg/corredor" "github.com/cortezaproject/corteza-server/pkg/filter" + "github.com/cortezaproject/corteza-server/pkg/flag" "github.com/cortezaproject/corteza-server/system/rest/request" "github.com/cortezaproject/corteza-server/system/service" "github.com/cortezaproject/corteza-server/system/service/event" @@ -64,9 +66,11 @@ func (ctrl *Application) List(ctx context.Context, r *request.ApplicationList) ( var ( err error f = types.ApplicationFilter{ - Name: r.Name, - Query: r.Query, - Labels: r.Labels, + Name: r.Name, + Query: r.Query, + Labels: r.Labels, + Flags: r.Flags, + IncFlags: r.IncFlags, Deleted: filter.State(r.Deleted), } @@ -131,6 +135,11 @@ func (ctrl *Application) Update(ctx context.Context, r *request.ApplicationUpdat func (ctrl *Application) Read(ctx context.Context, r *request.ApplicationRead) (interface{}, error) { app, err := ctrl.application.LookupByID(ctx, r.ApplicationID) + if err != nil { + return ctrl.makePayload(ctx, app, err) + } + + err = flag.Load(ctx, service.DefaultStore, r.IncFlags, auth.GetIdentityFromContext(ctx).Identity(), app) return ctrl.makePayload(ctx, app, err) } @@ -171,6 +180,44 @@ func (ctrl *Application) Reorder(ctx context.Context, r *request.ApplicationReor return api.OK(), ctrl.application.Reorder(ctx, order) } +func (ctrl *Application) FlagCreate(ctx context.Context, r *request.ApplicationFlagCreate) (interface{}, error) { + app, err := ctrl.application.LookupByID(ctx, r.ApplicationID) + if err != nil { + return nil, err + } + + if r.OwnedBy == 0 { + if !service.DefaultAccessControl.CanGlobalFlagApplication(ctx) { + return nil, service.ApplicationErrNotAllowedToManageFlagGlobal() + } + } else { + if !service.DefaultAccessControl.CanSelfFlagApplication(ctx) { + return nil, service.ApplicationErrNotAllowedToManageFlag() + } + } + + return api.OK(), flag.Create(ctx, service.DefaultStore, app, r.OwnedBy, r.Flag) +} + +func (ctrl *Application) FlagDelete(ctx context.Context, r *request.ApplicationFlagDelete) (interface{}, error) { + app, err := ctrl.application.LookupByID(ctx, r.ApplicationID) + if err != nil { + return nil, err + } + + if r.OwnedBy == 0 { + if !service.DefaultAccessControl.CanGlobalFlagApplication(ctx) { + return nil, service.ApplicationErrNotAllowedToManageFlagGlobal() + } + } else { + if !service.DefaultAccessControl.CanSelfFlagApplication(ctx) { + return nil, service.ApplicationErrNotAllowedToManageFlag() + } + } + + return api.OK(), flag.Delete(ctx, service.DefaultStore, app, r.OwnedBy, r.Flag) +} + func (ctrl Application) makePayload(ctx context.Context, m *types.Application, err error) (*applicationPayload, error) { if err != nil || m == nil { return nil, err diff --git a/system/rest/handlers/application.go b/system/rest/handlers/application.go index 35d414256..8960ef61a 100644 --- a/system/rest/handlers/application.go +++ b/system/rest/handlers/application.go @@ -22,6 +22,8 @@ type ( List(context.Context, *request.ApplicationList) (interface{}, error) Create(context.Context, *request.ApplicationCreate) (interface{}, error) Update(context.Context, *request.ApplicationUpdate) (interface{}, error) + FlagCreate(context.Context, *request.ApplicationFlagCreate) (interface{}, error) + FlagDelete(context.Context, *request.ApplicationFlagDelete) (interface{}, error) Read(context.Context, *request.ApplicationRead) (interface{}, error) Delete(context.Context, *request.ApplicationDelete) (interface{}, error) Undelete(context.Context, *request.ApplicationUndelete) (interface{}, error) @@ -34,6 +36,8 @@ type ( List func(http.ResponseWriter, *http.Request) Create func(http.ResponseWriter, *http.Request) Update func(http.ResponseWriter, *http.Request) + FlagCreate func(http.ResponseWriter, *http.Request) + FlagDelete func(http.ResponseWriter, *http.Request) Read func(http.ResponseWriter, *http.Request) Delete func(http.ResponseWriter, *http.Request) Undelete func(http.ResponseWriter, *http.Request) @@ -92,6 +96,38 @@ func NewApplication(h ApplicationAPI) *Application { api.Send(w, r, value) }, + FlagCreate: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewApplicationFlagCreate() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.FlagCreate(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + FlagDelete: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewApplicationFlagDelete() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.FlagDelete(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, Read: func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() params := request.NewApplicationRead() @@ -181,6 +217,8 @@ func (h Application) MountRoutes(r chi.Router, middlewares ...func(http.Handler) r.Get("/application/", h.List) r.Post("/application/", h.Create) r.Put("/application/{applicationID}", h.Update) + r.Post("/application/{applicationID}/flag/{ownedBy}/{flag}", h.FlagCreate) + r.Delete("/application/{applicationID}/flag/{ownedBy}/{flag}", h.FlagDelete) r.Get("/application/{applicationID}", h.Read) r.Delete("/application/{applicationID}", h.Delete) r.Post("/application/{applicationID}/undelete", h.Undelete) diff --git a/system/rest/request/application.go b/system/rest/request/application.go index 66cc485ee..89de2da1a 100644 --- a/system/rest/request/application.go +++ b/system/rest/request/application.go @@ -52,6 +52,16 @@ type ( // Labels Labels map[string]string + // Flags GET parameter + // + // Flags + Flags []string + + // IncFlags GET parameter + // + // Calculated (0, default), global (1) or return only (2) own flags + IncFlags uint + // Limit GET parameter // // Limit @@ -137,11 +147,50 @@ type ( Labels map[string]string } + ApplicationFlagCreate struct { + // ApplicationID PATH parameter + // + // Application ID + ApplicationID uint64 `json:",string"` + + // Flag PATH parameter + // + // Flag + Flag string + + // OwnedBy PATH parameter + // + // Owner; 0 = everyone + OwnedBy uint64 `json:",string"` + } + + ApplicationFlagDelete struct { + // ApplicationID PATH parameter + // + // Application ID + ApplicationID uint64 `json:",string"` + + // Flag PATH parameter + // + // Flag + Flag string + + // OwnedBy PATH parameter + // + // Owner; 0 = everyone + OwnedBy uint64 `json:",string"` + } + ApplicationRead struct { // ApplicationID PATH parameter // // Application ID ApplicationID uint64 `json:",string"` + + // IncFlags GET parameter + // + // Calculated (0, default), global (1) or return only (2) own flags + IncFlags uint } ApplicationDelete struct { @@ -190,6 +239,8 @@ func (r ApplicationList) Auditable() map[string]interface{} { "query": r.Query, "deleted": r.Deleted, "labels": r.Labels, + "flags": r.Flags, + "incFlags": r.IncFlags, "limit": r.Limit, "pageCursor": r.PageCursor, "sort": r.Sort, @@ -216,6 +267,16 @@ func (r ApplicationList) GetLabels() map[string]string { return r.Labels } +// Auditable returns all auditable/loggable parameters +func (r ApplicationList) GetFlags() []string { + return r.Flags +} + +// Auditable returns all auditable/loggable parameters +func (r ApplicationList) GetIncFlags() uint { + return r.IncFlags +} + // Auditable returns all auditable/loggable parameters func (r ApplicationList) GetLimit() uint { return r.Limit @@ -277,6 +338,23 @@ func (r *ApplicationList) Fill(req *http.Request) (err error) { return err } } + if val, ok := tmp["flags[]"]; ok { + r.Flags, err = val, nil + if err != nil { + return err + } + } else if val, ok := tmp["flags"]; ok { + r.Flags, err = val, nil + if err != nil { + return err + } + } + if val, ok := tmp["incFlags"]; ok && len(val) > 0 { + r.IncFlags, err = payload.ParseUint(val[0]), nil + if err != nil { + return err + } + } if val, ok := tmp["limit"]; ok && len(val) > 0 { r.Limit, err = payload.ParseUint(val[0]), nil if err != nil { @@ -554,6 +632,144 @@ func (r *ApplicationUpdate) Fill(req *http.Request) (err error) { return err } +// NewApplicationFlagCreate request +func NewApplicationFlagCreate() *ApplicationFlagCreate { + return &ApplicationFlagCreate{} +} + +// Auditable returns all auditable/loggable parameters +func (r ApplicationFlagCreate) Auditable() map[string]interface{} { + return map[string]interface{}{ + "applicationID": r.ApplicationID, + "flag": r.Flag, + "ownedBy": r.OwnedBy, + } +} + +// Auditable returns all auditable/loggable parameters +func (r ApplicationFlagCreate) GetApplicationID() uint64 { + return r.ApplicationID +} + +// Auditable returns all auditable/loggable parameters +func (r ApplicationFlagCreate) GetFlag() string { + return r.Flag +} + +// Auditable returns all auditable/loggable parameters +func (r ApplicationFlagCreate) GetOwnedBy() uint64 { + return r.OwnedBy +} + +// Fill processes request and fills internal variables +func (r *ApplicationFlagCreate) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "applicationID") + r.ApplicationID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + val = chi.URLParam(req, "flag") + r.Flag, err = val, nil + if err != nil { + return err + } + + val = chi.URLParam(req, "ownedBy") + r.OwnedBy, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + +// NewApplicationFlagDelete request +func NewApplicationFlagDelete() *ApplicationFlagDelete { + return &ApplicationFlagDelete{} +} + +// Auditable returns all auditable/loggable parameters +func (r ApplicationFlagDelete) Auditable() map[string]interface{} { + return map[string]interface{}{ + "applicationID": r.ApplicationID, + "flag": r.Flag, + "ownedBy": r.OwnedBy, + } +} + +// Auditable returns all auditable/loggable parameters +func (r ApplicationFlagDelete) GetApplicationID() uint64 { + return r.ApplicationID +} + +// Auditable returns all auditable/loggable parameters +func (r ApplicationFlagDelete) GetFlag() string { + return r.Flag +} + +// Auditable returns all auditable/loggable parameters +func (r ApplicationFlagDelete) GetOwnedBy() uint64 { + return r.OwnedBy +} + +// Fill processes request and fills internal variables +func (r *ApplicationFlagDelete) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "applicationID") + r.ApplicationID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + val = chi.URLParam(req, "flag") + r.Flag, err = val, nil + if err != nil { + return err + } + + val = chi.URLParam(req, "ownedBy") + r.OwnedBy, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + // NewApplicationRead request func NewApplicationRead() *ApplicationRead { return &ApplicationRead{} @@ -563,6 +779,7 @@ func NewApplicationRead() *ApplicationRead { func (r ApplicationRead) Auditable() map[string]interface{} { return map[string]interface{}{ "applicationID": r.ApplicationID, + "incFlags": r.IncFlags, } } @@ -571,6 +788,11 @@ func (r ApplicationRead) GetApplicationID() uint64 { return r.ApplicationID } +// Auditable returns all auditable/loggable parameters +func (r ApplicationRead) GetIncFlags() uint { + return r.IncFlags +} + // Fill processes request and fills internal variables func (r *ApplicationRead) Fill(req *http.Request) (err error) { if strings.ToLower(req.Header.Get("content-type")) == "application/json" { @@ -584,6 +806,18 @@ func (r *ApplicationRead) Fill(req *http.Request) (err error) { } } + { + // GET params + tmp := req.URL.Query() + + if val, ok := tmp["incFlags"]; ok && len(val) > 0 { + r.IncFlags, err = payload.ParseUint(val[0]), nil + if err != nil { + return err + } + } + } + { var val string // path params diff --git a/system/service/access_control.go b/system/service/access_control.go index 18e46563e..7c0f4a7ab 100644 --- a/system/service/access_control.go +++ b/system/service/access_control.go @@ -43,6 +43,8 @@ func (svc accessControl) Effective(ctx context.Context) (ee rbac.EffectiveSet) { ee.Push(types.SystemRBACResource, "settings.read", svc.CanReadSettings(ctx)) ee.Push(types.SystemRBACResource, "settings.manage", svc.CanManageSettings(ctx)) ee.Push(types.SystemRBACResource, "application.create", svc.CanCreateApplication(ctx)) + ee.Push(types.SystemRBACResource, "application.flag.self", svc.CanSelfFlagApplication(ctx)) + ee.Push(types.SystemRBACResource, "application.flag.global", svc.CanGlobalFlagApplication(ctx)) ee.Push(types.SystemRBACResource, "template.create", svc.CanCreateTemplate(ctx)) ee.Push(types.SystemRBACResource, "role.create", svc.CanCreateRole(ctx)) @@ -73,6 +75,14 @@ func (svc accessControl) CanCreateApplication(ctx context.Context) bool { return svc.can(ctx, types.SystemRBACResource, "application.create") } +func (svc accessControl) CanSelfFlagApplication(ctx context.Context) bool { + return svc.can(ctx, types.SystemRBACResource, "application.flag.self", rbac.Allowed) +} + +func (svc accessControl) CanGlobalFlagApplication(ctx context.Context) bool { + return svc.can(ctx, types.SystemRBACResource, "application.flag.global") +} + func (svc accessControl) CanCreateAuthClient(ctx context.Context) bool { return svc.can(ctx, types.SystemRBACResource, "authClient.create") } @@ -262,6 +272,8 @@ func (svc accessControl) Whitelist() rbac.Whitelist { "role.create", "user.create", "application.create", + "application.flag.self", + "application.flag.global", "template.create", "reminder.assign", ) diff --git a/system/service/application.go b/system/service/application.go index b3b286bf4..f8a802d3b 100644 --- a/system/service/application.go +++ b/system/service/application.go @@ -4,7 +4,9 @@ import ( "context" "github.com/cortezaproject/corteza-server/pkg/actionlog" + a "github.com/cortezaproject/corteza-server/pkg/auth" "github.com/cortezaproject/corteza-server/pkg/filter" + "github.com/cortezaproject/corteza-server/pkg/flag" "github.com/cortezaproject/corteza-server/pkg/label" "github.com/cortezaproject/corteza-server/store" "github.com/cortezaproject/corteza-server/system/service/event" @@ -102,6 +104,25 @@ func (svc *application) Search(ctx context.Context, af types.ApplicationFilter) } } + if len(af.Flags) > 0 { + af.FlaggedIDs, err = flag.Search( + ctx, + svc.store, + a.GetIdentityFromContext(ctx).Identity(), + (&types.Application{}).FlagResourceKind(), + af.Flags..., + ) + + if err != nil { + return err + } + + // flags specified byt no flagged resources found + if len(af.FlaggedIDs) == 0 { + return nil + } + } + if aa, f, err = store.SearchApplications(ctx, svc.store, af); err != nil { return err } @@ -110,6 +131,10 @@ func (svc *application) Search(ctx context.Context, af types.ApplicationFilter) return err } + if err = flag.Load(ctx, svc.store, f.IncFlags, a.GetIdentityFromContext(ctx).Identity(), toFlaggedApplications(aa)...); err != nil { + return err + } + return nil }() @@ -324,3 +349,19 @@ func toLabeledApplications(set []*types.Application) []label.LabeledResource { return ll } + +// toFlaggedApplications converts to []flag.FlaggedResource +// +// This function is auto-generated. +func toFlaggedApplications(set []*types.Application) []flag.FlaggedResource { + if len(set) == 0 { + return nil + } + + ll := make([]flag.FlaggedResource, len(set)) + for i := range set { + ll[i] = set[i] + } + + return ll +} diff --git a/system/service/application_actions.gen.go b/system/service/application_actions.gen.go index 78bb81eba..dbea76e9d 100644 --- a/system/service/application_actions.gen.go +++ b/system/service/application_actions.gen.go @@ -385,6 +385,46 @@ func ApplicationActionUndelete(props ...*applicationActionProps) *applicationAct return a } +// ApplicationActionFlagManage returns "system:application.flagManage" action +// +// This function is auto-generated. +// +func ApplicationActionFlagManage(props ...*applicationActionProps) *applicationAction { + a := &applicationAction{ + timestamp: time.Now(), + resource: "system:application", + action: "flagManage", + log: "managed flags for application {application}", + severity: actionlog.Notice, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// ApplicationActionFlagManageGlobal returns "system:application.flagManageGlobal" action +// +// This function is auto-generated. +// +func ApplicationActionFlagManageGlobal(props ...*applicationActionProps) *applicationAction { + a := &applicationAction{ + timestamp: time.Now(), + resource: "system:application", + action: "flagManageGlobal", + log: "managed global flags for application {application}", + severity: actionlog.Notice, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + // ********************************************************************************************************************* // ********************************************************************************************************************* // Error constructors @@ -673,6 +713,70 @@ func ApplicationErrNotAllowedToUndelete(mm ...*applicationActionProps) *errors.E return e } +// ApplicationErrNotAllowedToManageFlag returns "system:application.notAllowedToManageFlag" as *errors.Error +// +// +// This function is auto-generated. +// +func ApplicationErrNotAllowedToManageFlag(mm ...*applicationActionProps) *errors.Error { + var p = &applicationActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to manage flags for applications", nil), + + errors.Meta("type", "notAllowedToManageFlag"), + errors.Meta("resource", "system:application"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(applicationLogMetaKey{}, "failed to manage flags {application.name}; insufficient permissions"), + errors.Meta(applicationPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// ApplicationErrNotAllowedToManageFlagGlobal returns "system:application.notAllowedToManageFlagGlobal" as *errors.Error +// +// +// This function is auto-generated. +// +func ApplicationErrNotAllowedToManageFlagGlobal(mm ...*applicationActionProps) *errors.Error { + var p = &applicationActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to manage global flags for applications", nil), + + errors.Meta("type", "notAllowedToManageFlagGlobal"), + errors.Meta("resource", "system:application"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(applicationLogMetaKey{}, "failed to manage global flags {application.name}; insufficient permissions"), + errors.Meta(applicationPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + // ********************************************************************************************************************* // ********************************************************************************************************************* diff --git a/system/service/application_actions.yaml b/system/service/application_actions.yaml index 053685db6..36ce33161 100644 --- a/system/service/application_actions.yaml +++ b/system/service/application_actions.yaml @@ -50,6 +50,12 @@ actions: - action: undelete log: "undeleted {application}" + - action: flagManage + log: "managed flags for application {application}" + + - action: flagManageGlobal + log: "managed global flags for application {application}" + errors: - error: notFound message: "application not found" @@ -82,3 +88,11 @@ errors: - error: notAllowedToUndelete message: "not allowed to undelete this application" log: "failed to undelete {application.name}; insufficient permissions" + + - error: notAllowedToManageFlag + message: "not allowed to manage flags for applications" + log: "failed to manage flags {application.name}; insufficient permissions" + + - error: notAllowedToManageFlagGlobal + message: "not allowed to manage global flags for applications" + log: "failed to manage global flags {application.name}; insufficient permissions" diff --git a/system/types/applications.go b/system/types/applications.go index c86e09b63..98f02ecbb 100644 --- a/system/types/applications.go +++ b/system/types/applications.go @@ -23,6 +23,7 @@ type ( Unify *ApplicationUnify `json:"unify,omitempty"` Labels map[string]string `json:"labels,omitempty"` + Flags []string `json:"flags,omitempty"` CreatedAt time.Time `json:"createdAt,omitempty"` UpdatedAt *time.Time `json:"updatedAt,omitempty"` @@ -45,6 +46,10 @@ type ( LabeledIDs []uint64 `json:"-"` Labels map[string]string `json:"labels,omitempty"` + FlaggedIDs []uint64 `json:"-"` + Flags []string `json:"flags,omitempty"` + IncFlags uint `json:"-"` + Deleted filter.State `json:"deleted"` // Check fn is called by store backend for each resource found function can @@ -95,3 +100,25 @@ func (au *ApplicationUnify) Scan(value interface{}) error { func (au ApplicationUnify) Value() (driver.Value, error) { return json.Marshal(au) } + +// // // These will get generated later on + +// SetFlags adds new label to label map +func (a *Application) SetFlags(flags []string) { + a.Flags = flags +} + +// GetFlags returns current flags on the resource +func (a *Application) GetFlags() []string { + return a.Flags +} + +// FlagResourceKind returns the resource kind for the flag +func (*Application) FlagResourceKind() string { + return "system:application" +} + +// GetLabels adds new label to label map +func (a *Application) FlagResourceID() uint64 { + return a.ID +} diff --git a/tests/system/application_test.go b/tests/system/application_test.go index f6bd8acd6..f477321dd 100644 --- a/tests/system/application_test.go +++ b/tests/system/application_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + ft "github.com/cortezaproject/corteza-server/pkg/flag/types" "github.com/cortezaproject/corteza-server/pkg/id" "github.com/cortezaproject/corteza-server/store" "github.com/cortezaproject/corteza-server/system/service" @@ -39,12 +40,37 @@ func (h helper) repoMakeApplication(ss ...string) *types.Application { return res } +func (h helper) repoFlagApplication(appID, owner uint64, flag string, active bool) *ft.Flag { + res := &ft.Flag{ + Kind: "system:application", + ResourceID: appID, + OwnedBy: owner, + Name: flag, + Active: active, + } + + h.a.NoError(store.CreateFlag(context.Background(), service.DefaultStore, res)) + return res +} + func (h helper) lookupApplicationByID(ID uint64) *types.Application { res, err := store.LookupApplicationByID(context.Background(), service.DefaultStore, ID) h.noError(err) return res } +func (h helper) searchApplicationFlags(ID, owner uint64, flag string) ft.FlagSet { + res, _, err := store.SearchFlags(context.Background(), service.DefaultStore, ft.FlagFilter{ + Kind: "system:application", + ResourceID: []uint64{ID}, + OwnedBy: []uint64{owner}, + Name: []string{flag}, + }) + h.noError(err) + + return res +} + func (h helper) lookupApplicationByName(name string) *types.Application { res, _, err := store.SearchApplications(context.Background(), service.DefaultStore, types.ApplicationFilter{ Name: name, @@ -371,3 +397,256 @@ func TestApplicationLabels(t *testing.T) { req.NotNil(set.FindByID(ID).Labels) }) } + +func TestApplicationFlags(t *testing.T) { + h := newHelper(t) + h.clearApplications() + + h.allow(types.SystemRBACResource, "application.create") + + t.Run("create", func(t *testing.T) { + h.allow(types.SystemRBACResource, "application.flag.global") + res := h.repoMakeApplication() + + h.apiInit(). + Post(fmt.Sprintf("/application/%d/flag/%d/testFlag", res.ID, 0)). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() + + ff := h.searchApplicationFlags(res.ID, 0, "testFlag") + h.a.NotNil(ff) + h.a.Len(ff, 1) + }) + + t.Run("create; not allowed", func(t *testing.T) { + h.deny(types.SystemRBACResource, "application.flag.global") + res := h.repoMakeApplication() + + h.apiInit(). + Post(fmt.Sprintf("/application/%d/flag/%d/testFlag", res.ID, 0)). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertError("not allowed to manage global flags for applications")). + End() + }) + + t.Run("create own", func(t *testing.T) { + h.allow(types.SystemRBACResource, "application.flag.self") + res := h.repoMakeApplication() + h.repoFlagApplication(res.ID, 0, "testFlag", true) + + h.apiInit(). + Post(fmt.Sprintf("/application/%d/flag/%d/testFlag", res.ID, 10)). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() + + ff := h.searchApplicationFlags(res.ID, 0, "testFlag") + h.a.NotNil(ff) + h.a.Len(ff, 1) + + ff = h.searchApplicationFlags(res.ID, 10, "testFlag") + h.a.NotNil(ff) + h.a.Len(ff, 1) + }) + + t.Run("create own; not allowed", func(t *testing.T) { + h.deny(types.SystemRBACResource, "application.flag.self") + res := h.repoMakeApplication() + h.repoFlagApplication(res.ID, 0, "testFlag", true) + + h.apiInit(). + Post(fmt.Sprintf("/application/%d/flag/%d/testFlag", res.ID, 10)). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertError("not allowed to manage flags for applications")). + End() + }) + + t.Run("read application", func(t *testing.T) { + h.allow(types.ApplicationRBACResource.AppendWildcard(), "read") + res := h.repoMakeApplication() + h.repoFlagApplication(res.ID, 0, "testFlag", true) + + h.apiInit(). + Get(fmt.Sprintf("/application/%d", res.ID)). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + Assert(jsonpath.Present(`$.response.flags`)). + Assert(jsonpath.Len(`$.response.flags`, 1)). + Assert(jsonpath.Equal(`$.response.flags[0]`, "testFlag")). + End() + }) + + t.Run("list applications", func(t *testing.T) { + h.allow(types.ApplicationRBACResource.AppendWildcard(), "read") + h.clearApplications() + res := h.repoMakeApplication() + h.repoFlagApplication(res.ID, 0, "testFlag", true) + + h.apiInit(). + Get("/application/"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + Assert(jsonpath.Len(`$.response.set`, 1)). + Assert(jsonpath.Len(`$.response.set[0].flags`, 1)). + Assert(jsonpath.Equal(`$.response.set[0].flags[0]`, "testFlag")). + End() + }) + + t.Run("read application; with own flag", func(t *testing.T) { + h.allow(types.ApplicationRBACResource.AppendWildcard(), "read") + res := h.repoMakeApplication() + h.repoFlagApplication(res.ID, 0, "testFlag", true) + h.repoFlagApplication(res.ID, h.cUser.ID, "testFlagOwn", true) + + h.apiInit(). + Get(fmt.Sprintf("/application/%d", res.ID)). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + Assert(jsonpath.Present(`$.response.flags`)). + Assert(jsonpath.Len(`$.response.flags`, 2)). + End() + }) + + t.Run("read application; overwrite global", func(t *testing.T) { + h.allow(types.ApplicationRBACResource.AppendWildcard(), "read") + res := h.repoMakeApplication() + h.repoFlagApplication(res.ID, 0, "testFlag", true) + h.repoFlagApplication(res.ID, h.cUser.ID, "testFlag", false) + + h.apiInit(). + Get(fmt.Sprintf("/application/%d", res.ID)). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + Assert(jsonpath.NotPresent(`$.response.flags`)). + End() + }) + + t.Run("filter by flags", func(t *testing.T) { + flag := rs() + h.allow(types.ApplicationRBACResource.AppendWildcard(), "read") + h.repoMakeApplication() + h.repoMakeApplication() + res := h.repoMakeApplication() + h.repoFlagApplication(res.ID, 0, flag, true) + + h.apiInit(). + Get(fmt.Sprintf("/application/")). + QueryCollection(url.Values{"flags": []string{flag}}). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + Assert(jsonpath.Len("$.response.set", 1)). + End() + }) + + t.Run("filter by flags; self inactive", func(t *testing.T) { + flag := rs() + h.allow(types.ApplicationRBACResource.AppendWildcard(), "read") + h.repoMakeApplication() + h.repoMakeApplication() + res := h.repoMakeApplication() + h.repoFlagApplication(res.ID, 0, flag, true) + h.repoFlagApplication(res.ID, h.cUser.ID, flag, false) + + h.apiInit(). + Get(fmt.Sprintf("/application/")). + QueryCollection(url.Values{"flags": []string{flag}}). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + Assert(jsonpath.Len("$.response.set", 0)). + End() + }) +} + +func TestApplicationFlags_Flow1(t *testing.T) { + h := newHelper(t) + h.clearApplications() + + h.allow(types.SystemRBACResource, "application.create") + + t.Run("create", func(t *testing.T) { + h.allow(types.SystemRBACResource, "application.flag.global") + h.allow(types.SystemRBACResource, "application.flag.self") + res := h.repoMakeApplication() + a := h.apiInit() + + a.Post(fmt.Sprintf("/application/%d/flag/%d/testFlag", res.ID, h.cUser.ID)). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() + + a.Post(fmt.Sprintf("/application/%d/flag/%d/testFlag", res.ID, 0)). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() + + a.Delete(fmt.Sprintf("/application/%d/flag/%d/testFlag", res.ID, h.cUser.ID)). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() + + ff := h.searchApplicationFlags(res.ID, 0, "testFlag") + h.a.NotNil(ff) + h.a.Len(ff, 1) + h.a.True(ff[0].Active) + + ff = h.searchApplicationFlags(res.ID, h.cUser.ID, "testFlag") + h.a.NotNil(ff) + h.a.Len(ff, 1) + h.a.False(ff[0].Active) + + a.Delete(fmt.Sprintf("/application/%d/flag/%d/testFlag", res.ID, h.cUser.ID)). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() + + ff = h.searchApplicationFlags(res.ID, 0, "testFlag") + h.a.NotNil(ff) + h.a.Len(ff, 1) + h.a.True(ff[0].Active) + + ff = h.searchApplicationFlags(res.ID, h.cUser.ID, "testFlag") + h.a.NotNil(ff) + h.a.Len(ff, 1) + h.a.False(ff[0].Active) + + a.Post(fmt.Sprintf("/application/%d/flag/%d/testFlag", res.ID, h.cUser.ID)). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() + + ff = h.searchApplicationFlags(res.ID, 0, "testFlag") + h.a.NotNil(ff) + h.a.Len(ff, 1) + h.a.True(ff[0].Active) + + ff = h.searchApplicationFlags(res.ID, h.cUser.ID, "testFlag") + h.a.NotNil(ff) + h.a.Len(ff, 1) + h.a.True(ff[0].Active) + }) +}