strata-cli 0.1.11 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 371199d421786d4bc06748770b1155f59206d49f115eb0d2c664df2a0de139d8
4
- data.tar.gz: eb1ecac2dc8e40864f5c8ad278353ed8b8924f99fc54a2dad5c356f63dacd06d
3
+ metadata.gz: 060e97cfbb307efe69eff087983dc1d28e3e4008bd13f5529a7560e5d7e00f06
4
+ data.tar.gz: 134dbf05ad0b5870433fcb3c9a90e50853ed0a9f97c3378a97d208b4f6866dae
5
5
  SHA512:
6
- metadata.gz: 6f65de98ebf7994129da1cc2b6ae16a3591020e799ab61583afb1eea725f69b73fa6e35f3d8e69ebe0540bf8cdfe02f38a2f8d9f5c9e2ca606d0bf81536fa1d1
7
- data.tar.gz: f6e89761a4ef0fd8586d8a82a4262594f22f95a14f814abc6b48ab2d62e20e840429ee0dbf215dc58f2c8a4d350eace329d7be6528f1a079b2fb17a223cf88c2
6
+ metadata.gz: bf09f28b188e03929870b14515efb76d86f9aab034d048a3e2e5c49751f722718cf6a4d37a8c7d7e2966bd080aac758956df51951758a73b98a885003cbd5bb4
7
+ data.tar.gz: ab72ddfa33b44cc8da63cb10c2920575030fa53a4216d8d75eb9e2a38f2eebdc3f36afd3264c6dc81c00b440c6df4eb029df86126e600d16455def0cf311efb5
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "group"
4
+ require_relative "../output"
5
+
6
+ module Strata
7
+ module CLI
8
+ module Generators
9
+ # Appends a new policy entry to the project's security.yml.
10
+ # Receives a fully-resolved attrs hash from the Create subcommand.
11
+ class Policy < Group
12
+ argument :attrs, type: :hash
13
+
14
+ def append_policy_to_security_yml
15
+ security_file = "security.yml"
16
+
17
+ unless File.exist?(security_file)
18
+ raise Strata::CommandError,
19
+ "security.yml not found in the current directory. Run 'strata init' first."
20
+ end
21
+
22
+ content = File.read(security_file)
23
+ new_entry = format_policy_yaml(attrs)
24
+
25
+ updated = if content.match?(/^policies:\s*\[\]/)
26
+ # First policy — replace inline empty array with block sequence
27
+ content.sub(/^policies:\s*\[\]/, "policies:\n#{new_entry}")
28
+ else
29
+ # Subsequent policy — append after existing entries
30
+ content.rstrip + "\n#{new_entry}"
31
+ end
32
+
33
+ File.write(security_file, updated)
34
+ Output.print_status(:updated, security_file, type: :success, context: self)
35
+ end
36
+
37
+ private
38
+
39
+ def format_policy_yaml(a)
40
+ lines = []
41
+ lines << " - name: #{a[:name]}"
42
+ lines << " mode: #{a[:mode]}"
43
+ lines << " mask_value: \"#{a[:mask_value]}\"" if a[:mode] == "mask_data"
44
+ lines << " triggers:"
45
+
46
+ tags = Array(a[:field_tags])
47
+ lines << " field_tags: [#{tags.join(", ")}]"
48
+
49
+ field_names = Array(a[:field_names]).reject(&:empty?)
50
+ unless field_names.empty?
51
+ lines << " field_names:"
52
+ field_names.each { |n| lines << " - #{n}" }
53
+ end
54
+
55
+ lines << " context_dimension: #{a[:context_dimension]}"
56
+ lines << " permission_resolution:"
57
+ lines << " source: #{a[:permission_source]}"
58
+ lines << " value_from: #{a[:value_from]}"
59
+ lines << " tag_key: #{a[:tag_key]}" if a[:tag_key].to_s.strip.length > 0
60
+ lines << " bypass:"
61
+ lines << " system_admin: #{a[:bypass_system_admin]}"
62
+ lines << " project_admin: #{a[:bypass_project_admin]}"
63
+ lines.join("\n") + "\n"
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -57,7 +57,7 @@ module Strata
57
57
  def create_project_structure
58
58
  return if cloned_from_git?
59
59
 
60
- empty_directory uid
60
+ empty_directory uid unless existing_directory?
61
61
  empty_directory File.join(uid, "models")
62
62
  empty_directory File.join(uid, "tests")
63
63
  end
@@ -102,6 +102,12 @@ module Strata
102
102
  end
103
103
  end
104
104
 
105
+ def create_security_file
106
+ return if cloned_from_git?
107
+
108
+ copy_file "security.yml", "#{uid}/security.yml"
109
+ end
110
+
105
111
  def initialize_git
106
112
  return if cloned_from_git?
107
113
 
@@ -113,7 +119,8 @@ module Strata
113
119
 
114
120
  def setup_datasource
115
121
  return if cloned_from_git?
116
- return if options.key?(:datasource) # Already specified via CLI option
122
+ return if options.key?(:datasource)
123
+ return if existing_directory?
117
124
 
118
125
  print_status(:setup, "Let's configure your first datasource", type: :info)
119
126
  print_info("\n")
@@ -140,6 +147,10 @@ module Strata
140
147
  @cloned_from_git ||= false
141
148
  end
142
149
 
150
+ def existing_directory?
151
+ File.directory?(name.to_s)
152
+ end
153
+
143
154
  def fetch_project_info
144
155
  conn = Faraday.new(url: options[:source]) do |f|
145
156
  f.request :authorization, "Bearer", options[:api_key]
@@ -180,6 +191,8 @@ module Strata
180
191
  def uid
181
192
  @uid ||= if options.key?(:source) && @project_info
182
193
  @project_info["uid"] || options[:source].split("/").last
194
+ elsif existing_directory?
195
+ name.to_s
183
196
  else
184
197
  Utils.url_safe_str(name)
185
198
  end
@@ -28,11 +28,13 @@ Read this file end-to-end before creating or editing `models/**/*.yml`. Do not m
28
28
 
29
29
  These are non-negotiable. Violations break deploy, query planning, or production reports.
30
30
 
31
- 1. **Every field `name` is unique project-wide** — one name = one dimension or measure entity across all `tbl.*.yml` files. Strata has no `table.field` namespaces; bracket references like `[Total Revenue]` resolve by name alone.
31
+ 1. **Every field `name` is unique project-wide per dimension or measure** — one name = one dimension or measure entity across all `tbl.*.yml` files. Strata has no `table.field` namespaces; bracket references like `[Total Revenue]` resolve by name alone.
32
32
  2. **`many_to_many` join cardinality is not supported.** Use a junction/bridge table with two relationships (`one_to_many` + `many_to_one`) instead.
33
33
  3. **Measures on unrelated detail facts must have distinct names** — e.g. `Store Gross Sales`, `Catalog Gross Sales`, not one `Gross Sales` on three channel facts. See [Naming conventions](#naming-conventions).
34
- 4. **Dimensions may share names** when the business role is the same; Strata picks among tables at query time. **Measures do not combine that way** — duplicate measure names attach more SQL to the same measure entity (double-count risk).
34
+ 4. **Dimensions may share names** when the business role is the same; Strata picks among tables at query time. **Measures do not combine that way** — duplicate measure names implies varying levels of aggregation of the same basic fact. The correct table and measure combination will be determined by context (i.e. what dimensions are present in the same query.)
35
35
  5. **Cross-fact totals use compound measures or blending** — not the same measure name on multiple facts. See [Combined metrics across facts](#combined-metrics-across-facts).
36
+ 6. Security policies can be implemented in the security.yml file. Use the guidelines documented therin.
37
+ 7. Strata supports role playing or aliasing of dimension tables. For example, user wants date_dim table to also be used when query "Order Return Date". In that case, we simply point to the same physical_name "date_dim" but set the table name to "Order Return Date". If the user asks to alias an dimension table from an existing model we can simply copy the existing model, change the table name, and prefix all the dimensions in it with the appropriate prefix representing the role.
36
38
 
37
39
  ## Unsupported or impossible requirements
38
40
 
@@ -0,0 +1,77 @@
1
+ # Semantic Security Policies
2
+ #
3
+ # This file defines row-level security policies enforced at query planning time.
4
+ # Policies are branch-scoped and deployed alongside your semantic model.
5
+ #
6
+ # Two enforcement modes:
7
+ #
8
+ # mask_data — Sensitive column values are replaced with a mask string.
9
+ # Rows remain in the result set; users can see that restricted
10
+ # data exists but cannot read it.
11
+ #
12
+ # filter_data — Rows for context values the user cannot access are removed
13
+ # entirely. Restricted data is invisible to the user.
14
+ #
15
+ # How it works:
16
+ # 1. A policy is triggered when a query projects a field tagged with one of
17
+ # the trigger tags (e.g. "pii").
18
+ # 2. The context_dimension defines the security boundary (e.g. "Call Center ID").
19
+ # It must be reachable in every universe that contains a triggered field,
20
+ # otherwise the query is rejected with a hard error.
21
+ # 3. Permission resolution determines which context dimension values the current
22
+ # user is allowed to see, derived from their group memberships or user record.
23
+ #
24
+ # Tagging fields: add a tags key to any dimension in your table YAML files:
25
+ #
26
+ # fields:
27
+ # - type: dimension
28
+ # name: Agent Name
29
+ # tags: [pii]
30
+ # data_type: string
31
+ # expression:
32
+ # sql: agent_name
33
+
34
+ policies: []
35
+
36
+ # ── Example: mask PII dimensions scoped by call center ──────────────────────
37
+ #
38
+ # - name: PII Call Center Masking
39
+ # mode: mask_data # mask_data | filter_data
40
+ # mask_value: "#######" # Value shown to users without access (mask_data only)
41
+ #
42
+ # triggers:
43
+ # field_tags: [pii] # Triggers on any field tagged "pii"
44
+ # # field_names: # Optional: trigger on specific fields by display name
45
+ # # - Agent Name # Resolved to UIDs at deploy time
46
+ #
47
+ # # Dimension that scopes visibility. Resolved by name at deploy time.
48
+ # # Must be reachable in the universe for any query that includes a triggered field.
49
+ # context_dimension: Call Center ID
50
+ #
51
+ # permission_resolution:
52
+ # source: groups # groups | user
53
+ # value_from: tag # groups: name | tag / user: email | tag
54
+ # tag_key: call_center_id # Required when value_from is "tag".
55
+ # # Extracts the value from group tags formatted as "call_center_id:TMNT"
56
+ #
57
+ # bypass:
58
+ # system_admin: true # System admins bypass this policy entirely
59
+ # project_admin: false
60
+ #
61
+ # ── Example: filter rows so each user sees only their own employee record ────
62
+ #
63
+ # - name: Employee Self-Service
64
+ # mode: filter_data
65
+ #
66
+ # triggers:
67
+ # field_tags: [employee_pii]
68
+ #
69
+ # context_dimension: Employee Email
70
+ #
71
+ # permission_resolution:
72
+ # source: user # Resolve from the current user's own attributes
73
+ # value_from: email # Allowed value = the authenticated user's email address
74
+ #
75
+ # bypass:
76
+ # system_admin: true
77
+ # project_admin: false
@@ -28,6 +28,21 @@ module Strata
28
28
  MSG_MODELS_LIST_HEADER = "\n Semantic Models:\n"
29
29
  MSG_MODELS_COUNT = "\n Total: %d model(s)\n"
30
30
 
31
+ # Policy Command Prompts
32
+ MSG_POLICY_NAME = " Policy name:"
33
+ MSG_POLICY_MODE = " Enforcement mode:"
34
+ MSG_POLICY_MASK_VALUE = " Mask value (shown for restricted data):"
35
+ MSG_POLICY_FIELD_TAGS = " Field tags that trigger this policy (comma-separated, e.g. pii,hipaa):"
36
+ MSG_POLICY_FIELD_NAMES = " Specific field names (comma-separated, optional — leave blank to skip):"
37
+ MSG_POLICY_CONTEXT_DIM = " Context dimension name:"
38
+ MSG_POLICY_PERM_SOURCE = " Permission source:"
39
+ MSG_POLICY_VALUE_FROM = " Resolve allowed values from:"
40
+ MSG_POLICY_TAG_KEY = " Tag key (e.g. call_center_id):"
41
+ MSG_POLICY_BYPASS_ADMIN = " Bypass for system admins?"
42
+ MSG_POLICY_BYPASS_PROJECT = " Bypass for project admins?"
43
+ MSG_POLICY_CREATED = "\n✔ Policy '%s' added to security.yml"
44
+ MSG_POLICY_HINT = "\n💡 Run 'strata deploy' to activate this policy"
45
+
31
46
  # Migration hook options
32
47
  MIGRATION_HOOK_OPTIONS = {
33
48
  "pre (before deployment)" => "pre",
@@ -30,7 +30,7 @@ module Strata
30
30
  ALLOWED_FIELD_KEYS = %w[
31
31
  type name description hidden grains data_type display_type format
32
32
  secure disable_listing value_list_size snapshot exclusion_type
33
- exclusions inclusions extended_blend_group synonyms expression
33
+ exclusions inclusions extended_blend_group synonyms expression tags
34
34
  ].freeze
35
35
  ALLOWED_EXPRESSION_KEYS = %w[sql array lookup primary_key].freeze
36
36
  EXPRESSION_BOOLEAN_KEYS = %w[array lookup primary_key].freeze
@@ -51,6 +51,21 @@ module Strata
51
51
  end
52
52
  end
53
53
 
54
+ desc "policy [NAME]", "Scaffold a new security policy in security.yml"
55
+ method_option :interactive, aliases: "-i", type: :boolean, default: false,
56
+ desc: "Walk through all options interactively"
57
+ method_option :mode, type: :string, desc: "Enforcement mode: mask_data or filter_data"
58
+ method_option :tags, type: :array, desc: "Field tags that trigger this policy"
59
+ method_option :context, type: :string, desc: "Context dimension name"
60
+
61
+ def policy(name = nil)
62
+ attrs = policy_interactive_mode? ? collect_policy_interactive(name) : collect_policy_fast(name)
63
+ require_relative "../generators/policy"
64
+ Generators::Policy.new([attrs]).invoke_all
65
+ say MSG_POLICY_CREATED % attrs[:name], :green
66
+ say MSG_POLICY_HINT, :yellow
67
+ end
68
+
54
69
  desc "relation RELATION_PATH", "Create a relation (join) definition file"
55
70
  long_desc_from_file("create/relation")
56
71
 
@@ -122,6 +137,94 @@ module Strata
122
137
 
123
138
  private
124
139
 
140
+ # ── Policy helpers ────────────────────────────────────────────────────
141
+
142
+ def policy_interactive_mode?
143
+ options[:interactive] ||
144
+ (options[:mode].nil? && options[:tags].nil? && options[:context].nil?)
145
+ end
146
+
147
+ def collect_policy_fast(name)
148
+ {
149
+ name: name || "New Policy",
150
+ mode: options[:mode] || "mask_data",
151
+ mask_value: "#######",
152
+ field_tags: Array(options[:tags]),
153
+ field_names: [],
154
+ context_dimension: options[:context] || "<context_dimension>",
155
+ permission_source: "groups",
156
+ value_from: "tag",
157
+ tag_key: "<tag_key>",
158
+ bypass_system_admin: true,
159
+ bypass_project_admin: false
160
+ }
161
+ end
162
+
163
+ def collect_policy_interactive(name)
164
+ say "\n Policy\n", :bold
165
+ policy_name = name || prompt.ask(MSG_POLICY_NAME) { |q| q.required true }
166
+
167
+ mode = prompt.select(MSG_POLICY_MODE, [
168
+ {name: "mask_data — replace restricted values with a mask", value: "mask_data"},
169
+ {name: "filter_data — remove restricted rows entirely", value: "filter_data"}
170
+ ])
171
+
172
+ mask_value = if mode == "mask_data"
173
+ prompt.ask(MSG_POLICY_MASK_VALUE, default: "#######")
174
+ end
175
+
176
+ say "\n Triggers\n", :bold
177
+ tags_raw = prompt.ask(MSG_POLICY_FIELD_TAGS, default: "")
178
+ field_tags = tags_raw.to_s.split(",").map(&:strip).reject(&:empty?)
179
+
180
+ names_raw = prompt.ask(MSG_POLICY_FIELD_NAMES, default: "")
181
+ field_names = names_raw.to_s.split(",").map(&:strip).reject(&:empty?)
182
+
183
+ say "\n Context\n", :bold
184
+ context_dimension = prompt.ask(MSG_POLICY_CONTEXT_DIM) { |q| q.required true }
185
+
186
+ say "\n Permission Resolution\n", :bold
187
+ permission_source = prompt.select(MSG_POLICY_PERM_SOURCE, [
188
+ {name: "groups — derive from the user's group memberships", value: "groups"},
189
+ {name: "user — derive from the user's own attributes", value: "user"}
190
+ ])
191
+
192
+ value_from_choices = if permission_source == "groups"
193
+ [
194
+ {name: "tag — extract value from a group tag (e.g. call_center_id:TMNT)", value: "tag"},
195
+ {name: "name — use the group's display name as the allowed value", value: "name"}
196
+ ]
197
+ else
198
+ [
199
+ {name: "email — the user's email address", value: "email"},
200
+ {name: "tag — extract value from a user tag", value: "tag"}
201
+ ]
202
+ end
203
+ value_from = prompt.select(MSG_POLICY_VALUE_FROM, value_from_choices)
204
+
205
+ tag_key = if value_from == "tag"
206
+ prompt.ask(MSG_POLICY_TAG_KEY) { |q| q.required true }
207
+ end
208
+
209
+ say "\n Bypass\n", :bold
210
+ bypass_system_admin = prompt.yes?(MSG_POLICY_BYPASS_ADMIN, default: true)
211
+ bypass_project_admin = prompt.yes?(MSG_POLICY_BYPASS_PROJECT, default: false)
212
+
213
+ {
214
+ name: policy_name,
215
+ mode: mode,
216
+ mask_value: mask_value,
217
+ field_tags: field_tags,
218
+ field_names: field_names,
219
+ context_dimension: context_dimension,
220
+ permission_source: permission_source,
221
+ value_from: value_from,
222
+ tag_key: tag_key,
223
+ bypass_system_admin: bypass_system_admin,
224
+ bypass_project_admin: bypass_project_admin
225
+ }
226
+ end
227
+
125
228
  def handle_table_creation_with_path(table_path)
126
229
  # Parse the path to extract schema, physical name, and model path
127
230
  parsed = parse_table_path(table_path)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Strata
4
4
  module CLI
5
- VERSION = "0.1.11"
5
+ VERSION = "0.1.12"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: strata-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.11
4
+ version: 0.1.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ajo Abraham
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: 0.4.2
19
+ version: 0.4.4
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: 0.4.2
26
+ version: 0.4.4
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: aws-sdk-athena
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -268,6 +268,7 @@ files:
268
268
  - lib/strata/cli/generators/datasource.rb
269
269
  - lib/strata/cli/generators/group.rb
270
270
  - lib/strata/cli/generators/migration.rb
271
+ - lib/strata/cli/generators/policy.rb
271
272
  - lib/strata/cli/generators/project.rb
272
273
  - lib/strata/cli/generators/relation.rb
273
274
  - lib/strata/cli/generators/table.rb
@@ -286,6 +287,7 @@ files:
286
287
  - lib/strata/cli/generators/templates/migration.swap.yml
287
288
  - lib/strata/cli/generators/templates/project.yml
288
289
  - lib/strata/cli/generators/templates/rel.domain.yml
290
+ - lib/strata/cli/generators/templates/security.yml
289
291
  - lib/strata/cli/generators/templates/strata.yml
290
292
  - lib/strata/cli/generators/templates/table.table_name.yml
291
293
  - lib/strata/cli/generators/templates/test.yml