Skip to content

Commit

Permalink
Tn/add project vars (#934)
Browse files Browse the repository at this point in the history
* add global variables table

* set up global vars ui and backend

* add tests / test data

* remove logs

* fix tests

* resolve TODOS

* add button to add new variables

* remove pagination

* change text

* clean up outdated comments

* update copyright

* remove ID from DTO

* delete unneeded permissions

* shorten name for brevity

* shorten name for brevity lowercase

* add dash to match standard convention

* allow for longer documents

* remove copypasta

* remove more copypasta

* remove unused imports

* remove values from main screen
  • Loading branch information
Tyler Noblett authored Oct 2, 2023
1 parent 6a7aafa commit 26d5492
Show file tree
Hide file tree
Showing 28 changed files with 795 additions and 8 deletions.
8 changes: 8 additions & 0 deletions backend/database/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package database
import (
"database/sql"
"fmt"
"strings"
"time"

"github.com/ashirt-ops/ashirt-server/backend/logging"
Expand Down Expand Up @@ -160,6 +161,13 @@ func IsAlreadyExistsError(err error) bool {
return ok && mysqlErr.Number == 1062
}

// When updating a row using sq, the above function isAlreadyExistsError won't work
// (because extra text is appended to the error message)
// so this function manually checks for error code 1062
func IsAlreadyExistsErrorSq(err error) bool {
return strings.Contains(err.Error(), "1062")
}

func addDuplicatesClause(query squirrel.InsertBuilder, onDuplicates ...interface{}) (squirrel.InsertBuilder, error) {
if len(onDuplicates) == 0 {
return query, nil
Expand Down
12 changes: 12 additions & 0 deletions backend/database/seeding/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,18 @@ func newServiceWorkerGen(first int64) func(name, config string) models.ServiceWo
}
}

func newGlobalVarGen(first int64) func(name, value string) models.GlobalVar {
id := iotaLike(first)
return func(name, value string) models.GlobalVar {
return models.GlobalVar{
ID: id(),
Value: value,
Name: name,
CreatedAt: time.Now(),
}
}
}

// associateEvidenceToTag mirrors associateTagsToEvidence. Rather than associating multiple tags
// with a single piece of evidence this will instead associate a single tag to multiple evidence.
func associateEvidenceToTag(tag models.Tag, evis ...models.Evidence) []models.TagEvidenceMap {
Expand Down
11 changes: 11 additions & 0 deletions backend/database/seeding/hp_seed_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ var HarryPotterSeedData = Seeder{
ServiceWorkers: []models.ServiceWorker{
DemoServiceWorker,
},
GlobalVars: []models.GlobalVar{
VarExpelliarmus, VarAlohomora, VarAscendio, VarImperio, VarLumos, VarObliviate,
},
}

var newHPUser = newUserGen(1, func(f, l string) string { return strings.ToLower(f + "." + strings.Replace(l, " ", "", -1)) })
Expand Down Expand Up @@ -361,3 +364,11 @@ var FindingBook2Robes = newHPFinding(OpChamberOfSecrets.ID, "find-uuid-robes", n

var newHPServiceWorker = newServiceWorkerGen(1)
var DemoServiceWorker = newHPServiceWorker("Demo", `{ "type": "web", "version": 1, "url": "http://demo:3001/process" }`)

var newGlobalVar = newGlobalVarGen(1)
var VarExpelliarmus = newGlobalVar("Expelliarmus", "disarm an opponent")
var VarAlohomora = newGlobalVar("Alohomora", "unlock doors")
var VarAscendio = newGlobalVar("Ascendio", "lifts the caster high into the air")
var VarImperio = newGlobalVar("Imperio", "control another person")
var VarLumos = newGlobalVar("Lumos", "creates a narrow beam of light")
var VarObliviate = newGlobalVar("Obliviate", "erases memories")
10 changes: 10 additions & 0 deletions backend/database/seeding/seeder.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type Seeder struct {
EviFindingsMap []models.EvidenceFindingMap
Queries []models.Query
ServiceWorkers []models.ServiceWorker
GlobalVars []models.GlobalVar
}

// AllInitialTagIds is a (convenience) method version of the function TagIDsFromTags
Expand Down Expand Up @@ -262,6 +263,15 @@ func (seed Seeder) ApplyTo(db *database.Connection) error {
"deleted_at": seed.ServiceWorkers[i].DeletedAt,
}
})
tx.BatchInsert("global_vars", len(seed.GlobalVars), func(i int) map[string]interface{} {
return map[string]interface{}{
"id": seed.GlobalVars[i].ID,
"name": seed.GlobalVars[i].Name,
"value": seed.GlobalVars[i].Value,
"created_at": seed.GlobalVars[i].CreatedAt,
"updated_at": seed.GlobalVars[i].UpdatedAt,
}
})
})

return err
Expand Down
11 changes: 11 additions & 0 deletions backend/database/seeding/test_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ func ClearDB(db *database.Connection) error {
tx.Delete(sq.Delete("queries"))
tx.Delete(sq.Delete("operations"))
tx.Delete(sq.Delete("service_workers"))
tx.Delete(sq.Delete("global_vars"))
})
return err
}
Expand Down Expand Up @@ -605,6 +606,16 @@ func GetFavoriteForOperation(t *testing.T, db *database.Connection, slug string,
return isFavorite
}

func GetGlobalVarFromName(t *testing.T, db *database.Connection, name string) models.GlobalVar {
var globalVar models.GlobalVar

err := db.Get(&globalVar, sq.Select("*").
From("global_vars").
Where(sq.Eq{"name": name}))
require.NoError(t, err)
return globalVar
}

type TestOptions struct {
DatabasePath *string
DatabaseName *string
Expand Down
5 changes: 5 additions & 0 deletions backend/dtos/dtos.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,8 @@ type ActiveServiceWorker struct {
type Flags struct {
Flags []string `json:"flags"`
}

type GlobalVar struct {
Name string `json:"name"`
Value string `json:"value"`
}
1 change: 1 addition & 0 deletions backend/dtos/gentypes/generate_typescript_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ func main() {
gen(dtos.UserGroup{})
gen(dtos.UserGroupAdminView{})
gen(dtos.UserGroupOperationRole{})
gen(dtos.GlobalVar{})

// Since this file only contains typescript types, webpack doesn't pick up the
// changes unless there is some actual executable javascript referenced from
Expand Down
12 changes: 12 additions & 0 deletions backend/migrations/20230922175734-add-global-vars.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- +migrate Up
CREATE TABLE global_vars (
id INT AUTO_INCREMENT,
name VARCHAR(255) NOT NULL UNIQUE,
value VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

-- +migrate Down
DROP TABLE global_vars;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- +migrate Up
ALTER TABLE global_vars
MODIFY COLUMN value TEXT;
-- +migrate Down
ALTER TABLE global_vars
MODIFY COLUMN value VARCHAR(255);
9 changes: 9 additions & 0 deletions backend/models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,12 @@ type ServiceWorker struct {
UpdatedAt *time.Time `db:"updated_at"`
DeletedAt *time.Time `db:"deleted_at"`
}

// GlobalVar reflects the structure of the database table 'global_vars'
type GlobalVar struct {
ID int64 `db:"id"`
Name string `db:"name"`
Value string `db:"value"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt *time.Time `db:"updated_at"`
}
24 changes: 21 additions & 3 deletions backend/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,24 @@ CREATE TABLE `findings` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
/*!40101 SET character_set_client = @saved_cs_client */;

--
-- Table structure for table `global_vars`
--

DROP TABLE IF EXISTS `global_vars`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `global_vars` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`value` text,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
/*!40101 SET character_set_client = @saved_cs_client */;

--
-- Table structure for table `gorp_migrations`
--
Expand Down Expand Up @@ -490,7 +508,7 @@ CREATE TABLE `users` (
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;

-- Dump completed on 2023-03-24 12:59:30
-- Dump completed on 2023-09-28 14:50:12
-- MySQL dump 10.13 Distrib 8.0.31, for Linux (aarch64)
--
-- Host: localhost Database: migrate_db
Expand All @@ -514,7 +532,7 @@ CREATE TABLE `users` (

LOCK TABLES `gorp_migrations` WRITE;
/*!40000 ALTER TABLE `gorp_migrations` DISABLE KEYS */;
INSERT INTO `gorp_migrations` VALUES ('20190705190058-create-users-table.sql','2023-03-24 12:59:29'),('20190708185420-create-operations-table.sql','2023-03-24 12:59:29'),('20190708185427-create-events-table.sql','2023-03-24 12:59:29'),('20190708185432-create-evidence-table.sql','2023-03-24 12:59:29'),('20190708185441-create-evidence-event-map-table.sql','2023-03-24 12:59:29'),('20190716190100-create-user-operation-map-table.sql','2023-03-24 12:59:29'),('20190722193434-create-tags-table.sql','2023-03-24 12:59:29'),('20190722193937-create-tag-event-map.sql','2023-03-24 12:59:29'),('20190909183500-add-short-name-to-users-table.sql','2023-03-24 12:59:29'),('20190909190416-add-short-name-index.sql','2023-03-24 12:59:29'),('20190926205116-evidence-name.sql','2023-03-24 12:59:29'),('20190930173342-add-saved-searches.sql','2023-03-24 12:59:29'),('20191001182541-evidence-tags.sql','2023-03-24 12:59:29'),('20191008005212-add-uuid-to-events-evidence.sql','2023-03-24 12:59:29'),('20191015235306-add-slug-to-operations.sql','2023-03-24 12:59:29'),('20191018172105-modular-auth.sql','2023-03-24 12:59:29'),('20191023170906-codeblock.sql','2023-03-24 12:59:29'),('20191101185207-replace-events-with-findings.sql','2023-03-24 12:59:30'),('20191114211948-add-operation-to-tags.sql','2023-03-24 12:59:30'),('20191205182830-create-api-keys-table.sql','2023-03-24 12:59:30'),('20191213222629-users-with-email.sql','2023-03-24 12:59:30'),('20200103194053-rename-short-name-to-slug.sql','2023-03-24 12:59:30'),('20200104013804-rework-ashirt-auth.sql','2023-03-24 12:59:30'),('20200116070736-add-admin-flag.sql','2023-03-24 12:59:30'),('20200130175541-fix-color-truncation.sql','2023-03-24 12:59:30'),('20200205200208-disable-user-support.sql','2023-03-24 12:59:30'),('20200215015330-optional-user-id.sql','2023-03-24 12:59:30'),('20200221195107-deletable-user.sql','2023-03-24 12:59:30'),('20200303215004-move-last-login.sql','2023-03-24 12:59:30'),('20200306221628-add-explicit-headless.sql','2023-03-24 12:59:30'),('20200331155258-finding-status.sql','2023-03-24 12:59:30'),('20200617193248-case-senitive-apikey.sql','2023-03-24 12:59:30'),('20200928160958-add-totp-secret-to-auth-table.sql','2023-03-24 12:59:30'),('20210120205510-create-email-queue-table.sql','2023-03-24 12:59:30'),('20210401220807-dynamic-categories.sql','2023-03-24 12:59:30'),('20210408212206-remove-findings-category.sql','2023-03-24 12:59:30'),('20210730170543-add-auth-type.sql','2023-03-24 12:59:30'),('20220211181557-add-default-tags.sql','2023-03-24 12:59:30'),('20220512174013-evidence-metadata.sql','2023-03-24 12:59:30'),('20220516163424-add-worker-services.sql','2023-03-24 12:59:30'),('20220811153414-webauthn-credentials.sql','2023-03-24 12:59:30'),('20220908193523-switch-to-username.sql','2023-03-24 12:59:30'),('20220912185024-add-is_favorite.sql','2023-03-24 12:59:30'),('20220916190855-remove-null-as-value-for-is_favorite.sql','2023-03-24 12:59:30'),('20221027152757-remove-operation-status.sql','2023-03-24 12:59:30'),('20221111221242-create-user-operation-preferences.sql','2023-03-24 12:59:30'),('20221121165342-add-groups.sql','2023-03-24 12:59:30'),('20221216195811-add-user-group-permissions-table.sql','2023-03-24 12:59:30'),('20230324124303-add-authn-id.sql','2023-03-24 12:59:31');
INSERT INTO `gorp_migrations` VALUES ('20190705190058-create-users-table.sql','2023-09-28 14:50:10'),('20190708185420-create-operations-table.sql','2023-09-28 14:50:10'),('20190708185427-create-events-table.sql','2023-09-28 14:50:10'),('20190708185432-create-evidence-table.sql','2023-09-28 14:50:10'),('20190708185441-create-evidence-event-map-table.sql','2023-09-28 14:50:10'),('20190716190100-create-user-operation-map-table.sql','2023-09-28 14:50:10'),('20190722193434-create-tags-table.sql','2023-09-28 14:50:10'),('20190722193937-create-tag-event-map.sql','2023-09-28 14:50:10'),('20190909183500-add-short-name-to-users-table.sql','2023-09-28 14:50:10'),('20190909190416-add-short-name-index.sql','2023-09-28 14:50:10'),('20190926205116-evidence-name.sql','2023-09-28 14:50:10'),('20190930173342-add-saved-searches.sql','2023-09-28 14:50:10'),('20191001182541-evidence-tags.sql','2023-09-28 14:50:10'),('20191008005212-add-uuid-to-events-evidence.sql','2023-09-28 14:50:10'),('20191015235306-add-slug-to-operations.sql','2023-09-28 14:50:10'),('20191018172105-modular-auth.sql','2023-09-28 14:50:11'),('20191023170906-codeblock.sql','2023-09-28 14:50:11'),('20191101185207-replace-events-with-findings.sql','2023-09-28 14:50:11'),('20191114211948-add-operation-to-tags.sql','2023-09-28 14:50:11'),('20191205182830-create-api-keys-table.sql','2023-09-28 14:50:11'),('20191213222629-users-with-email.sql','2023-09-28 14:50:11'),('20200103194053-rename-short-name-to-slug.sql','2023-09-28 14:50:11'),('20200104013804-rework-ashirt-auth.sql','2023-09-28 14:50:11'),('20200116070736-add-admin-flag.sql','2023-09-28 14:50:11'),('20200130175541-fix-color-truncation.sql','2023-09-28 14:50:11'),('20200205200208-disable-user-support.sql','2023-09-28 14:50:11'),('20200215015330-optional-user-id.sql','2023-09-28 14:50:11'),('20200221195107-deletable-user.sql','2023-09-28 14:50:11'),('20200303215004-move-last-login.sql','2023-09-28 14:50:11'),('20200306221628-add-explicit-headless.sql','2023-09-28 14:50:11'),('20200331155258-finding-status.sql','2023-09-28 14:50:11'),('20200617193248-case-senitive-apikey.sql','2023-09-28 14:50:11'),('20200928160958-add-totp-secret-to-auth-table.sql','2023-09-28 14:50:11'),('20210120205510-create-email-queue-table.sql','2023-09-28 14:50:11'),('20210401220807-dynamic-categories.sql','2023-09-28 14:50:11'),('20210408212206-remove-findings-category.sql','2023-09-28 14:50:11'),('20210730170543-add-auth-type.sql','2023-09-28 14:50:11'),('20220211181557-add-default-tags.sql','2023-09-28 14:50:11'),('20220512174013-evidence-metadata.sql','2023-09-28 14:50:11'),('20220516163424-add-worker-services.sql','2023-09-28 14:50:11'),('20220811153414-webauthn-credentials.sql','2023-09-28 14:50:11'),('20220908193523-switch-to-username.sql','2023-09-28 14:50:11'),('20220912185024-add-is_favorite.sql','2023-09-28 14:50:11'),('20220916190855-remove-null-as-value-for-is_favorite.sql','2023-09-28 14:50:12'),('20221027152757-remove-operation-status.sql','2023-09-28 14:50:12'),('20221111221242-create-user-operation-preferences.sql','2023-09-28 14:50:12'),('20221121165342-add-groups.sql','2023-09-28 14:50:12'),('20221216195811-add-user-group-permissions-table.sql','2023-09-28 14:50:12'),('20230324124303-add-authn-id.sql','2023-09-28 14:50:12'),('20230922175734-add-global-vars.sql','2023-09-28 14:50:12'),('20230928144308-change-global-var-value-to-text.sql','2023-09-28 14:50:12');
/*!40000 ALTER TABLE `gorp_migrations` ENABLE KEYS */;
UNLOCK TABLES;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
Expand All @@ -527,4 +545,4 @@ UNLOCK TABLES;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;

-- Dump completed on 2023-03-24 12:59:30
-- Dump completed on 2023-09-28 14:50:12
38 changes: 38 additions & 0 deletions backend/server/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -987,4 +987,42 @@ func bindServiceWorkerRoutes(r chi.Router, db *database.Connection) {
}
return nil, services.SetFavoriteOperation(r.Context(), db, i)
}))

route(r, "GET", "/global-vars", jsonHandler(func(r *http.Request) (interface{}, error) {
return services.ListGlobalVars(r.Context(), db)
}))

route(r, "POST", "/global-vars", jsonHandler(func(r *http.Request) (interface{}, error) {
dr := dissectJSONRequest(r)
i := services.CreateGlobalVarInput{
Name: dr.FromBody("name").Required().AsString(),
Value: dr.FromBody("value").AsString(),
}
if dr.Error != nil {
return nil, dr.Error
}
return services.CreateGlobalVar(r.Context(), db, i)
}))

route(r, "PUT", "/global-vars/{name}", jsonHandler(func(r *http.Request) (interface{}, error) {
dr := dissectJSONRequest(r)
i := services.UpdateGlobalVarInput{
Name: dr.FromURL("name").Required().AsString(),
Value: dr.FromBody("value").AsString(),
NewName: dr.FromBody("newName").AsString(),
}
if dr.Error != nil {
return nil, dr.Error
}
return nil, services.UpdateGlobalVar(r.Context(), db, i)
}))

route(r, "DELETE", "/global-vars/{name}", jsonHandler(func(r *http.Request) (interface{}, error) {
dr := dissectJSONRequest(r)
name := dr.FromURL("name").Required().AsString()
if dr.Error != nil {
return nil, dr.Error
}
return nil, services.DeleteGlobalVar(r.Context(), db, name)
}))
}
138 changes: 138 additions & 0 deletions backend/services/global_vars.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copyright 2023, Yahoo Inc.
// Licensed under the terms of the MIT. See LICENSE file in project root for terms.

package services

import (
"context"

"github.com/ashirt-ops/ashirt-server/backend"
"github.com/ashirt-ops/ashirt-server/backend/database"
"github.com/ashirt-ops/ashirt-server/backend/dtos"
"github.com/ashirt-ops/ashirt-server/backend/models"
"github.com/ashirt-ops/ashirt-server/backend/policy"
"github.com/ashirt-ops/ashirt-server/backend/server/middleware"

sq "github.com/Masterminds/squirrel"
)

type CreateGlobalVarInput struct {
Name string
OwnerID int64
Value string
}

type UpdateGlobalVarInput struct {
Name string
Value string
NewName string
}

type DeleteGlobalVarInput struct {
Name string
}

func CreateGlobalVar(ctx context.Context, db *database.Connection, i CreateGlobalVarInput) (*dtos.GlobalVar, error) {
if err := policy.Require(middleware.Policy(ctx), policy.AdminUsersOnly{}); err != nil {
return nil, backend.WrapError("Unable to create global variable", backend.UnauthorizedWriteErr(err))
}

if i.Name == "" {
return nil, backend.MissingValueErr("Name")
}

_, err := db.Insert("global_vars", map[string]interface{}{
"name": i.Name,
"value": i.Value,
})
if err != nil {
if database.IsAlreadyExistsError(err) {
return nil, backend.BadInputErr(backend.WrapError("global variable already exists", err), "A global variable with this name already exists")
}
return nil, backend.WrapError("Unable to add new global variable", backend.DatabaseErr(err))
}

return &dtos.GlobalVar{
Name: i.Name,
Value: i.Value,
}, nil
}

func DeleteGlobalVar(ctx context.Context, db *database.Connection, name string) error {
if err := policyRequireWithAdminBypass(ctx, policy.AdminUsersOnly{}); err != nil {
return backend.WrapError("Unwilling to delete global variable", backend.UnauthorizedWriteErr(err))
}

err := db.Delete(sq.Delete("global_vars").Where(sq.Eq{"name": name}))
if err != nil {
return backend.WrapError("Cannot delete global variable", backend.DatabaseErr(err))
}

return nil
}

func ListGlobalVars(ctx context.Context, db *database.Connection) ([]*dtos.GlobalVar, error) {
if err := policy.Require(middleware.Policy(ctx), policy.AdminUsersOnly{}); err != nil {
return nil, backend.WrapError("Unwilling to list global variables", backend.UnauthorizedReadErr(err))
}

var globalVars = make([]models.GlobalVar, 0)
err := db.Select(&globalVars, sq.Select("*").
From("global_vars").
OrderBy("name ASC"))

if err != nil {
return nil, backend.WrapError("Cannot list global variables", backend.DatabaseErr(err))
}

var globalVarsDTO = make([]*dtos.GlobalVar, len(globalVars))
for i, globalVar := range globalVars {
globalVarsDTO[i] = &dtos.GlobalVar{
Name: globalVar.Name,
Value: globalVar.Value,
}
}

return globalVarsDTO, nil
}

func UpdateGlobalVar(ctx context.Context, db *database.Connection, i UpdateGlobalVarInput) error {
globalVar, err := LookupGlobalVar(db, i.Name)
if err != nil {
return backend.WrapError("Unable to update operation", backend.UnauthorizedWriteErr(err))
}

if err := policyRequireWithAdminBypass(ctx, policy.AdminUsersOnly{}); err != nil {
return backend.WrapError("Unwilling to update operation", backend.UnauthorizedWriteErr(err))
}

var val string
var name string

if i.Value != "" {
val = i.Value
} else {
val = globalVar.Value
}

if i.NewName != "" {
name = i.NewName
} else {
name = globalVar.Name
}

err = db.Update(sq.Update("global_vars").
SetMap(map[string]interface{}{
"value": val,
"name": name,
}).
Where(sq.Eq{"id": globalVar.ID}))
if err != nil {
if database.IsAlreadyExistsErrorSq(err) {
return backend.BadInputErr(backend.WrapError("Global variable already exists", err), "A global variable with this name already exists")
}
return backend.WrapError("Cannot update global variable", backend.DatabaseErr(err))
}

return nil
}
Loading

0 comments on commit 26d5492

Please sign in to comment.