Skip to content

Commit f101dca

Browse files
authored
Merge pull request #14 from ApplauseOSS/feat/support-workspaces
feat: add support workspaces
2 parents d1048f1 + 535c965 commit f101dca

6 files changed

Lines changed: 87 additions & 32 deletions

File tree

.github/workflows/go-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@ jobs:
1212
- uses: actions/checkout@v4
1313
- uses: actions/setup-go@v5
1414
with:
15-
go-version: '1.24'
15+
go-version: '1.25'
1616
- run: go test ./...

.github/workflows/golanci-lint.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
name: lint
1515
strategy:
1616
matrix:
17-
go-version: [1.24.x]
17+
go-version: [1.25.x]
1818
platform: [ubuntu-latest]
1919
runs-on: ${{ matrix.platform }}
2020
steps:

README.md

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
# Snowflizzle
22

3-
Snowflizzle is a tool for declaratively managing Snowflake roles, users, and their permissions using a YAML configuration file. It enables mapping users (by login name or email) to Snowflake roles, and automates the granting and revoking of database, schema, and table privileges, including support for wildcards and partial name matching. The tool is designed for automation and integrates with Snowflake using a service user and key-pair authentication.
3+
Snowflizzle is a tool for declaratively managing Snowflake roles, users, and their permissions using a YAML configuration file. It enables mapping users (by login name or email) to Snowflake roles, and automates the granting and revoking of database, schema, table, and workspace privileges, including support for wildcards and partial name matching. The tool is designed for automation and integrates with Snowflake using a service user and key-pair authentication.
44

55
## Key Features
66

77
- Declarative YAML configuration for roles, members, and permissions
8-
- Grant and revoke privileges on databases, schemas, and tables
8+
- Grant and revoke privileges on databases, schemas, tables, views, and workspaces
99
- Supports wildcard and partial matching for schema and table names
1010
- Dry-run mode for previewing changes without applying them
1111
- Validation of configuration files before applying changes
@@ -22,7 +22,7 @@ roles:
2222
- email: exemployee2@example.com
2323
removed: true
2424
permissions:
25-
# Option for names
25+
# Option for names
2626
# - database_name
2727
databases:
2828
- name: test_a_db
@@ -31,26 +31,56 @@ roles:
3131
- name: test_b_db
3232
remove: true
3333
schemas:
34-
# Options for names
35-
# - database_name.schema_name
36-
# - database_name.*
37-
# - database_name.*schema_partial
38-
# - database_name.schema_partial*
34+
# Options for names
35+
# - database_name.schema_name
36+
# - database_name.*
37+
# - database_name.*schema_partial
38+
# - database_name.schema_partial*
3939
- name: test_c_db.credentials
4040
grants:
4141
- USAGE
4242
- name: test_b_db.assets
4343
tables:
44-
# Options for names
45-
# - database_name.*.*
46-
# - database_name.schema_name.*
47-
# - database_name.schema_partial_*.*
48-
# - database_name.*_schema_partial.*
49-
# - database_name.schema_name.table_name
44+
# Options for names
45+
# - database_name.*.*
46+
# - database_name.schema_name.*
47+
# - database_name.schema_partial_*.*
48+
# - database_name.*_schema_partial.*
49+
# - database_name.schema_name.table_name
5050
- name: test_c_db.credentials
5151
grants:
5252
- SELECT
5353
- name: test_b_db.*.*
54+
workspaces:
55+
# Name must be a 3-part identifier: database_name.schema_name.workspace_name
56+
# Quote the value if the workspace name contains spaces.
57+
# Supported grants: USAGE, READ, WRITE
58+
- name: "test_a_db.my_schema.My Workspace"
59+
grants:
60+
- USAGE
61+
- READ
62+
- WRITE
63+
```
64+
65+
### Workspace grants
66+
67+
Snowflake [workspaces](https://docs.snowflake.com/en/user-guide/ui-snowsight-workspaces) are managed under the `workspaces` key in `permissions`. Each entry requires a 3-part name (`DATABASE.SCHEMA.WORKSPACE_NAME`) and one or more of the following privileges:
68+
69+
| Privilege | Description |
70+
| --------- | -------------------------------------------- |
71+
| `USAGE` | Allows the role to see the workspace |
72+
| `READ` | Allows the role to read workspace contents |
73+
| `WRITE` | Allows the role to modify workspace contents |
74+
75+
If a workspace name contains spaces, wrap the entire `name` value in single quotes:
76+
77+
```yaml
78+
workspaces:
79+
- name: "MY_DATABASE.MY_SCHEMA.SALESOPS WORKSPACE"
80+
grants:
81+
- USAGE
82+
- READ
83+
- WRITE
5484
```
5585

5686
Prepare snowflizzle service user in snowflake

config/snowflake.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"errors"
2424
"fmt"
2525
"os"
26+
"path/filepath"
2627

2728
"github.com/applauseoss/snowflizzle/internal/logging"
2829
"github.com/joho/godotenv"
@@ -109,7 +110,7 @@ func ConnectToSnowflake() (*sql.DB, error) {
109110
}
110111

111112
func parsePrivateKeyFromFile(path string) (*rsa.PrivateKey, error) {
112-
bytes, err := os.ReadFile(path)
113+
bytes, err := os.ReadFile(filepath.Clean(path)) //nolint:gosec // G703: path is sourced from a trusted operator-controlled environment variable
113114
if err != nil {
114115
return nil, err
115116
}

go.mod

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
module github.com/applauseoss/snowflizzle
22

3-
go 1.24
4-
5-
toolchain go1.24.1
3+
go 1.25
64

75
require (
86
github.com/DATA-DOG/go-sqlmock v1.5.2

internal/role/role.go

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,21 @@ type ViewPermission struct {
7676
Remove bool `yaml:"remove,omitempty"`
7777
}
7878

79-
// RolePermissions represents permissions for a role, including databases, schemas, tables, and views.
79+
// WorkspacePermission represents a Snowflake workspace permission entry.
80+
// Example: { name: "MYDB.MYSCHEMA.MY WORKSPACE", grants: ["USAGE", "READ", "WRITE"] }
81+
type WorkspacePermission struct {
82+
Name string `yaml:"name"`
83+
Grants []string `yaml:"grants,omitempty"`
84+
Remove bool `yaml:"remove,omitempty"`
85+
}
86+
87+
// RolePermissions represents permissions for a role, including databases, schemas, tables, views, and workspaces.
8088
type RolePermissions struct {
81-
Databases []DatabasePermission `yaml:"databases,omitempty"`
82-
Schemas []SchemaPermission `yaml:"schemas,omitempty"`
83-
Tables []TablePermission `yaml:"tables,omitempty"`
84-
Views []ViewPermission `yaml:"views,omitempty"`
89+
Databases []DatabasePermission `yaml:"databases,omitempty"`
90+
Schemas []SchemaPermission `yaml:"schemas,omitempty"`
91+
Tables []TablePermission `yaml:"tables,omitempty"`
92+
Views []ViewPermission `yaml:"views,omitempty"`
93+
Workspaces []WorkspacePermission `yaml:"workspaces,omitempty"`
8594
}
8695

8796
// Role represents a single role entry in the YAML configuration.
@@ -477,7 +486,7 @@ func (rp *RoleProcessor) memberProcess(role Role) (usersSkipped int, err error)
477486
return usersSkipped, err
478487
}
479488
}
480-
return
489+
return usersSkipped, err
481490
}
482491

483492
func (rp *RoleProcessor) execQuery(query string) error {
@@ -703,7 +712,7 @@ func (rp *RoleProcessor) Process() error {
703712
if role.Remove {
704713
// If the role exists, drop it
705714
if _, exists := rp.existingRoles[upperRole]; exists {
706-
dropRoleQuery := "DROP ROLE IF EXISTS " + rp.qRole
715+
dropRoleQuery := "DROP ROLE IF EXISTS " + rp.qRole //nolint:gosec // G202: role name is safely quoted by quoteIdentifier
707716
if rp.dryRun {
708717
rp.logger.InfoContext(context.Background(), "[DryRun] Would drop role", "query", dropRoleQuery)
709718
} else {
@@ -781,7 +790,7 @@ func (rp *RoleProcessor) FindNonExistentRoles() []string {
781790
// GetSchemasInDatabase fetches all schemas in a given database.
782791
func (rp *RoleProcessor) GetSchemasInDatabase(dbName string) ([]string, error) {
783792
ctx := context.Background()
784-
query := "SHOW SCHEMAS IN DATABASE " + quoteIdentifier(dbName)
793+
query := "SHOW SCHEMAS IN DATABASE " + quoteIdentifier(dbName) //nolint:gosec // G202: dbName is safely quoted by quoteIdentifier
785794
rows, err := rp.db.QueryContext(ctx, query)
786795
if err != nil {
787796
rp.logger.ErrorContext(context.Background(), "SHOW SCHEMAS failed", "database", dbName, "error", err)
@@ -839,7 +848,7 @@ func patternMatches(pattern, candidate string) bool {
839848
// GetTablesInDatabase fetches all tables in a given database, grouped by schema.
840849
func (rp *RoleProcessor) GetTablesInDatabase(dbName string) (map[string][]string, error) {
841850
ctx := context.Background()
842-
query := "SHOW TABLES IN DATABASE " + quoteIdentifier(dbName)
851+
query := "SHOW TABLES IN DATABASE " + quoteIdentifier(dbName) //nolint:gosec // G202: dbName is safely quoted by quoteIdentifier
843852
rows, err := rp.db.QueryContext(ctx, query)
844853
if err != nil {
845854
return nil, err
@@ -880,7 +889,7 @@ func (rp *RoleProcessor) GetTablesInDatabase(dbName string) (map[string][]string
880889
// GetViewsInDatabase fetches all views in a given database, grouped by schema.
881890
func (rp *RoleProcessor) GetViewsInDatabase(dbName string) (map[string][]string, error) {
882891
ctx := context.Background()
883-
query := "SHOW VIEWS IN DATABASE " + quoteIdentifier(dbName)
892+
query := "SHOW VIEWS IN DATABASE " + quoteIdentifier(dbName) //nolint:gosec // G202: dbName is safely quoted by quoteIdentifier
884893
rows, err := rp.db.QueryContext(ctx, query)
885894
if err != nil {
886895
errStr := strings.ToLower(err.Error())
@@ -985,6 +994,14 @@ func (rp *RoleProcessor) ValidateConfigObjects(role Role) error {
985994
}
986995
}
987996
}
997+
// Workspaces: validate name is a 3-part identifier (DATABASE.SCHEMA.WORKSPACE)
998+
for _, ws := range role.Permissions.Workspaces {
999+
parts := strings.SplitN(ws.Name, ".", 3)
1000+
if len(parts) != 3 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" || strings.TrimSpace(parts[2]) == "" {
1001+
errs = append(errs, fmt.Errorf("workspace permission name must be in format DATABASE.SCHEMA.WORKSPACE_NAME, got: %q", ws.Name))
1002+
}
1003+
}
1004+
9881005
if len(errs) > 0 {
9891006
return errors.New("configuration validation errors")
9901007
}
@@ -1019,7 +1036,7 @@ func (rp *RoleProcessor) FetchRoleGrants(roleName string) ([]FetchedRoleGrant, e
10191036
logger := rp.logger
10201037
ctx := context.Background()
10211038
qRole := quoteIdentifier(strings.ToUpper(roleName))
1022-
query := "SHOW GRANTS TO ROLE " + qRole
1039+
query := "SHOW GRANTS TO ROLE " + qRole //nolint:gosec // G202: role name is safely quoted by quoteIdentifier
10231040
rows, err := rp.db.QueryContext(ctx, query)
10241041
if err != nil {
10251042
logger.ErrorContext(context.Background(), "query SHOW GRANTS TO ROLE failed", "error", err, "role", roleName)
@@ -1182,6 +1199,14 @@ func (rp *RoleProcessor) buildDesiredGrantsFromConfig(role Role) map[GrantKey]st
11821199
}
11831200
}
11841201

1202+
// Workspaces
1203+
for _, ws := range role.Permissions.Workspaces {
1204+
for _, priv := range ws.Grants {
1205+
gk := GrantKey{Privilege: strings.ToUpper(priv), ObjectType: "WORKSPACE", ObjectName: normalizeObjectName(ws.Name)}
1206+
grants[gk] = struct{}{}
1207+
}
1208+
}
1209+
11851210
// Add warehouse usage grant as part of desired grants because it is a default requirement
11861211
warehouseName := rp.roleName + "_WAREHOUSE"
11871212
gk := GrantKey{
@@ -1227,7 +1252,8 @@ func (rp *RoleProcessor) syncRoleGrants(role Role) {
12271252
role.Permissions == nil || (len(role.Permissions.Databases) == 0 &&
12281253
len(role.Permissions.Schemas) == 0 &&
12291254
len(role.Permissions.Tables) == 0 &&
1230-
len(role.Permissions.Views) == 0) {
1255+
len(role.Permissions.Views) == 0 &&
1256+
len(role.Permissions.Workspaces) == 0) {
12311257
return
12321258
}
12331259

0 commit comments

Comments
 (0)