mikras_utils 0.3.2 → 0.4.0

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.
@@ -1,4 +1,21 @@
1
1
  module MkAcl
2
+ # Generates the functions
3
+ #
4
+ # acl_portal.user_is_role(_user_id integer, _object_id integer, _roles text[])
5
+ # acl_portal.user_is_role(_user_id integer, _object_id integer, _roles text)
6
+ # Returns true if the user has one of the given roles on the domain
7
+ # object
8
+ #
9
+ # public.current_is_role(_object_id integer, _roles text[])
10
+ # public.current_is_role(_object_id integer, _roles text)
11
+ # Returns true if the current user has one of the given roles on the
12
+ # domain object
13
+ #
14
+ # public.current_is_ROLE(_object_id integer)
15
+ # ROLE is one of the role kinds. Returns true if the current user has the
16
+ # given role
17
+ #
18
+
2
19
  class Generator
3
20
  class RoleFunctions
4
21
  using String::Text
@@ -20,54 +37,34 @@ module MkAcl
20
37
  private
21
38
  def generate_user_role_functions
22
39
  # TODO: Test. Then combine with per-table methods
23
- signature = "#{acl_schema}.user_is_role(_user_id integer, _domain_id integer, _roles text[])"
40
+ signature = "#{acl_schema}.user_is_role(_user_id integer, _object_id integer, _roles text[])"
24
41
  puts %(
25
42
  -- Return true if the user possess one or more of the given roles on the
26
- -- domain record (cases, events, visits) with the given ID
43
+ -- object (cases, events, visits) with the given ID
27
44
  --
28
45
  drop function if exists #{signature} cascade;
29
46
  create function #{signature} returns boolean as $$
30
47
  select exists (
31
48
  select
32
- from #{app_schema}.case_roles cr
33
- join #{app_schema}.case_role_users cru on cru.case_role_id = cr.id
34
- where cr.case_id = _domain_id
35
- and cru.user_id = _user_id
36
- and cr.kind = any(_roles)
37
-
38
- union
39
-
40
- select
41
- from #{app_schema}.event_roles cr
42
- join #{app_schema}.event_role_users cru on cru.event_role_id = cr.id
43
- where cr.event_id = _domain_id
44
- and cru.user_id = _user_id
45
- and cr.kind = any(_roles)
46
-
47
- union
48
-
49
- select
50
- from #{app_schema}.visit_roles cr
51
- join #{app_schema}.visit_role_users cru on cru.visit_role_id = cr.id
52
- where cr.visit_id = _domain_id
53
- and cru.user_id = _user_id
54
- and cr.kind = any(_roles)
55
-
56
- );
49
+ from app_portal.domain_users
50
+ where domain_id = _domain_id
51
+ and user_id = _user_id
52
+ and role = any(_roles)
53
+ )
57
54
  $$ language sql
58
55
  security definer;
59
56
  ).align
60
57
  puts
61
58
 
62
- signature = "#{acl_schema}.user_is_role(_user_id integer, _domain_id integer, role text)"
59
+ signature = "#{acl_schema}.user_is_role(_user_id integer, _object_id integer, role text)"
63
60
  puts %(
64
- -- Return true if the user has the given role on the domain record
61
+ -- Return true if the user has the given role on the object
65
62
  -- (cases, events, visits). Note that this function overloads the multi-role
66
63
  -- version
67
64
  --
68
65
  drop function if exists #{signature} cascade;
69
66
  create function #{signature} returns boolean as $$
70
- select #{acl_schema}.user_is_role(_user_id, _domain_id, array[role]);
67
+ select #{acl_schema}.user_is_role(_user_id, _object_id, array[role]);
71
68
  $$ language sql
72
69
  security definer;
73
70
  ).align
@@ -75,13 +72,13 @@ module MkAcl
75
72
 
76
73
  for domain, roles in DOMAINS.zip([CASE_ROLES, EVENT_ROLES, VISIT_ROLES])
77
74
  for role in roles
78
- signature = "#{acl_schema}.user_is_#{role.downcase}(_user_id integer, _domain_id integer)"
75
+ signature = "#{acl_schema}.user_is_#{role.downcase}(_user_id integer, _object_id integer)"
79
76
  puts %(
80
77
  -- Return true if the user possess the '#{role}' role
81
78
  --
82
79
  drop function if exists #{signature} cascade;
83
80
  create function #{signature} returns boolean as $$
84
- select #{acl_schema}.user_is_role(_user_id, _domain_id, '#{role}');
81
+ select #{acl_schema}.user_is_role(_user_id, _object_id, '#{role}');
85
82
  $$ language sql
86
83
  security definer;
87
84
  ).align
@@ -92,28 +89,28 @@ module MkAcl
92
89
 
93
90
  def generate_current_role_functions
94
91
  # TODO: Test. Then combine with per-table methods
95
- signature = "public.current_is_role(_domain_id integer, _roles text[])"
92
+ signature = "public.current_is_role(_object_id integer, _roles text[])"
96
93
  puts %(
97
94
  -- Return true if the current user possess one or more of the given roles on the
98
- -- domain record (cases, events, visits) with the given ID
95
+ -- object (cases, events, visits) with the given ID
99
96
  --
100
97
  drop function if exists #{signature} cascade;
101
98
  create function #{signature} returns boolean as $$
102
- select #{acl_schema}.user_is_role(public.current_user_id(), _domain_id, _roles);
99
+ select #{acl_schema}.user_is_role(public.current_user_id(), _object_id, _roles);
103
100
  $$ language sql
104
101
  security definer;
105
102
  ).align
106
103
  puts
107
104
 
108
- signature = "public.current_is_role(_domain_id integer, _role text)"
105
+ signature = "public.current_is_role(_object_id integer, _role text)"
109
106
  puts %(
110
- -- Return true if the current user possess the given role on the domain record
107
+ -- Return true if the current user possess the given role on the object
111
108
  -- (cases, events, visits). Note that this function overloads the multi-role
112
109
  -- version
113
110
  --
114
111
  drop function if exists #{signature} cascade;
115
112
  create function #{signature} returns boolean as $$
116
- select #{acl_schema}.user_is_role(public.current_user_id(), _domain_id, array[_role]);
113
+ select #{acl_schema}.user_is_role(public.current_user_id(), _object_id, array[_role]);
117
114
  $$ language sql
118
115
  security definer;
119
116
  ).align
@@ -121,13 +118,13 @@ module MkAcl
121
118
 
122
119
  for domain, roles in DOMAINS.zip([CASE_ROLES, EVENT_ROLES, VISIT_ROLES])
123
120
  for role in roles
124
- signature = "public.current_is_#{role.downcase}(_domain_id integer)"
121
+ signature = "public.current_is_#{role.downcase}(_object_id integer)"
125
122
  puts %(
126
123
  -- Return true if the current user possess the '#{role}' role
127
124
  --
128
125
  drop function if exists #{signature} cascade;
129
126
  create function #{signature} returns boolean as $$
130
- select #{acl_schema}.user_is_role(public.current_user_id(), _domain_id, '#{role}');
127
+ select #{acl_schema}.user_is_role(public.current_user_id(), _object_id, '#{role}');
131
128
  $$ language sql
132
129
  security definer;
133
130
  ).align
@@ -56,13 +56,13 @@ module MkAcl
56
56
  for action in %w(select update delete).map { table.actions[_1] }
57
57
  rule_expr = action.rules.map { |rule|
58
58
  exprs = []
59
- exprs << "acl_#{action.name}[#{rule.index}:#{rule.index}][1:] && public.current_role_ids()" \
59
+ exprs << "acl_#{action.name}[#{rule.ordinal}:#{rule.ordinal}][1:] && public.current_role_ids()" \
60
60
  if !rule.roles.empty?
61
61
  exprs << "(#{rule.using})" if rule.using
62
62
  "(#{exprs.join(' and ')})"
63
63
  }.join(" or ")
64
64
  rule_expr = "false" if rule_expr.empty?
65
-
65
+
66
66
  puts %(
67
67
  drop policy if exists rls_#{action.name} on #{table.uid} cascade;
68
68
  create policy rls_#{action.name} on #{table.uid} for #{action.name}
@@ -0,0 +1,80 @@
1
+
2
+ module MkAcl
3
+ class Generator
4
+ class Seeds
5
+ using String::Text
6
+
7
+ attr_reader :generator
8
+ forward_to :generator, :conn, :spec, :app_schema, :acl_schema
9
+
10
+ def initialize(generator)
11
+ @generator = generator
12
+ end
13
+
14
+ def generate
15
+ clean_tables
16
+ generate_seeds
17
+ end
18
+
19
+ def self.generate(generator) self.new(generator).generate end
20
+
21
+ private
22
+ def clean_tables
23
+ puts %(
24
+ delete from acl_portal.acl_rules;
25
+ delete from acl_portal.acl_actions;
26
+ delete from acl_portal.acl_tables;
27
+ ).align
28
+ end
29
+
30
+ def generate_seeds
31
+ for table in spec.tables
32
+ puts %(
33
+ insert into acl_portal.acl_tables (
34
+ schema_name, table_name, domain,
35
+ parent_schema_name, parent_table_name, parent_link_field,
36
+ acl)
37
+ values (
38
+ '#{app_schema}', '#{table}', #{conn.quote_value(table.domain)},
39
+ '#{table.parent && table.app_schema}', '#{table.parent}', '#{table.parent_link_field}',
40
+ #{table.acl || 'false'})
41
+ returning id as "table_id"
42
+ \\gset
43
+ ).align
44
+ puts
45
+
46
+ table.actions.values.each { |action|
47
+ puts %(
48
+ insert into acl_portal.acl_actions (acl_table_id, kind)
49
+ values (:table_id, '#{action.name.upcase}')
50
+ returning id as "action_id"
51
+ \\gset
52
+ ).align
53
+ puts
54
+
55
+ action.rules.each { |rule|
56
+ fields = %w(roles filter assert fields tables ordinal)
57
+ values = fields.map { |field| conn.quote_value(rule.send(field.to_sym), elem_type: :text) }
58
+ puts %(
59
+ insert into acl_portal.acl_rules (acl_action_id, roles, filter, assert, fields, tables, ordinal)
60
+ values (:action_id, #{values.join(', ')});
61
+ ).align
62
+ puts
63
+ }
64
+ }
65
+ end
66
+
67
+ puts %(
68
+ update acl_portal.acl_tables sub
69
+ set parent_id = (
70
+ select id
71
+ from acl_portal.acl_tables super
72
+ where super.schema_name = sub.parent_schema_name
73
+ and super.table_name = sub.parent_table_name
74
+ );
75
+ ).align
76
+ puts
77
+ end
78
+ end
79
+ end
80
+ end
@@ -2,14 +2,29 @@
2
2
  module MkAcl
3
3
  class Parser
4
4
  attr_reader :file
5
+ attr_reader :symtab
5
6
 
6
- def initialize(file) @file = file end
7
+ def initialize(file)
8
+ @file = file
9
+ @symtab = SimpleSymtab::SimpleSymtab.new
10
+ end
7
11
  def parse() parse_spec end
8
12
  def self.parse(file) Parser.new(file).parse end
9
13
 
10
14
  private
11
15
  def error(*msg) raise ParseError, *msg end
12
- def norm_array(value) value.is_a?(Array) ? value : value&.split end
16
+
17
+ def norm_value(value)
18
+ value && symtab.interpolate(value)
19
+ end
20
+
21
+ def norm_array(value)
22
+ if value.is_a?(Array)
23
+ value.map { |v| norm_value(v) }
24
+ else
25
+ norm_value(value)&.split
26
+ end
27
+ end
13
28
 
14
29
  def parse_spec
15
30
  hash = YAML.load(IO.read(file), symbolize_names: true)
@@ -18,21 +33,31 @@ module MkAcl
18
33
  app_schema = schema[:app] or raise ArgumentError, "Can't find 'schema.app' attribute"
19
34
  acl_schema = schema[:acl] or raise ArgumentError, "Can't find 'schema.acl' attribute"
20
35
  spec = Spec.new(file, app_schema, acl_schema)
36
+ parse_variables(hash)
21
37
  parse_tables(spec, hash)
22
38
  spec
23
39
  end
24
40
 
41
+ def parse_variables(hash)
42
+ hash.delete_if { |k,v| k.to_s =~ /^(\$[A-Za-z0-9_]+)$/ and symtab[k] = v }
43
+ end
44
+
25
45
  def parse_tables(spec, tables)
26
46
  for table_name, actions in tables
27
- table = Table.new(spec, table_name, actions.delete(:domain))
47
+ acl = actions.key?(:acl) ? actions.delete(:acl) : true
48
+ parent = actions.delete(:parent)
49
+ domain = actions.delete(:domain)
50
+ table = Table.new(spec, table_name, domain, parent, acl)
28
51
  parse_actions(table, actions)
29
52
  end
30
53
  end
31
54
 
32
55
  def parse_actions(table, actions)
33
56
  for action_name, rules in actions
34
- constrain?(action_name, :insert, :select, :update, :delete) or error "Illegal action '#{action_name}'"
35
- constrain?(rules, String, Array, Hash) or error "Illegal value for #{action} action '#{rules}'"
57
+ constrain?(action_name, :insert, :select, :update, :delete, :attach) or
58
+ error "Illegal action '#{action_name}'"
59
+ constrain?(rules, String, Array, Hash) or
60
+ error "Illegal value for #{action} action '#{rules}'"
36
61
  action = Action.new(table, action_name)
37
62
 
38
63
  # Normalize rules
@@ -40,7 +65,7 @@ module MkAcl
40
65
  when Hash
41
66
  rules = [rules]
42
67
  when String
43
- rules = [{ role: rules }]
68
+ rules = [{ roles: rules }]
44
69
  end
45
70
 
46
71
  parse_rules(action, rules)
@@ -48,23 +73,22 @@ module MkAcl
48
73
  end
49
74
 
50
75
  def parse_rules(action, rules)
51
- index = 0
76
+ ordinal = 0
52
77
  for entry in rules
53
- rule = Rule.new(action, index += 1)
54
-
78
+ rule = Rule.new(action, ordinal += 1)
55
79
  for key, value in entry
56
80
  case key
57
- when :role; rule.roles = norm_array(value)
58
- when :using; rule.using = value
59
- when :check; rule.check = value
60
- when :include; rule.include = norm_array(value)
61
- when :exclude; rule.exclude = norm_array(value)
81
+ when :roles; rule.roles = norm_array(value)
82
+ when :filter; rule.filter = norm_value(value)
83
+ when :assert; rule.assert = norm_value(value)
84
+ when :fields; rule.fields = norm_array(value)
85
+ when :tables; rule.tables = norm_array(value)
62
86
  else
63
87
  raise ArgumentError, "Illegal field '#{key}' in #{action.table}.#{action}"
64
88
  end
65
89
  end
66
90
 
67
- !action.rules.empty? or
91
+ !action.rules.empty? or
68
92
  error "At least one rule is required in #{action.table}.#{action}"
69
93
  end
70
94
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'forward_to'
5
+
6
+ include ForwardTo
7
+
8
+ module SimpleSymtab
9
+ class Error < StandardError; end
10
+
11
+ class SimpleSymtab
12
+ def [](key)
13
+ resolve if !resolved?
14
+ @variables[key]
15
+ end
16
+
17
+ def []=(key, value)
18
+ !@variables.key?(key) or raise Error, "Redefinition of '#{key}'"
19
+ @variables[key] = value
20
+ @unresolved << key
21
+ end
22
+
23
+ forward_to :"@variables", :key?, :size, :empty?
24
+
25
+ def initialize(hash = {})
26
+ @variables = hash.dup
27
+ @unresolved = @variables.keys
28
+ end
29
+
30
+ def interpolate(s)
31
+ resolve if !resolved?
32
+ resolve_string(s)
33
+ end
34
+
35
+ private
36
+ def resolved?() @unresolved.empty? end
37
+
38
+ def resolve_string(val)
39
+ val.is_a?(String) or raise ArgumentError
40
+ seen = Set.new
41
+ while val =~ /(\$[A-Za-z0-9_]+\b)/
42
+ var = $1
43
+ seen.add?(var) or raise Error, "Circular definition of '#{var}'"
44
+ rep = @variables[var.to_sym] or raise Error, "Unknown variable '#{var}'"
45
+ val.gsub!(var, rep)
46
+ end
47
+ val
48
+ end
49
+
50
+ def resolve
51
+ @unresolved.each { |var|
52
+ @variables[var] = resolve_string(@variables[var])
53
+ }
54
+ @unresolved = []
55
+ end
56
+ end
57
+ end
58
+
@@ -2,7 +2,7 @@
2
2
  module MkAcl
3
3
  class Spec
4
4
  # Source SPEC file. Only for informational purposes
5
- attr_reader :file
5
+ attr_reader :file
6
6
 
7
7
  # Application schema that contains the ACL controlled tables
8
8
  attr_reader :app_schema
@@ -11,7 +11,7 @@ module MkAcl
11
11
  # (relatively) clean
12
12
  attr_reader :acl_schema
13
13
 
14
- # List of tables. Initialized by Table::initialize through #attach_table
14
+ # List of tables. Maintained by #attach_table
15
15
  attr_reader :tables
16
16
 
17
17
  # Spec acts as a hash from table name to Table object. Initialized by
@@ -45,11 +45,35 @@ module MkAcl
45
45
 
46
46
  class Table
47
47
  attr_reader :spec
48
- attr_accessor :parents # Parent TableSpec objects. Initialized by the analyzer
49
- attr_reader :uid # SCHEMA.TABLE name
48
+ forward_to :@spec, :app_schema, :acl_schema
49
+
50
+ # Hash from referenced table name to a tuple of the table object and the
51
+ # link field. Initialized by the analyzer
52
+ attr_accessor :references
53
+
54
+ # Table name and uid
50
55
  attr_reader :name
51
- attr_reader :record_name # Associated record name. Used in function names
52
- attr_accessor :domain # Security domain - either 'case' or 'event'. Initialized by the analyzer
56
+ attr_reader :uid # SCHEMA.TABLE name
57
+
58
+ # Parent domain table. Initialized by the analyzer
59
+ attr_accessor :parent
60
+
61
+ # Name of parent table. May be nil. Initialized by the parser
62
+ attr_accessor :parent_name
63
+
64
+ # Name of link field to parent record. Initialized by the analyzer
65
+ attr_accessor :parent_link_field
66
+
67
+ # Security domain name for this object. Domain object have themselves as
68
+ # domain, all other portal objects use the parent's domain. Initialized by
69
+ # the analyzer
70
+ attr_accessor :domain
71
+
72
+ # SQL to create the ACL for a table. No ACL if false, default ACL if nil
73
+ attr_accessor :acl
74
+
75
+ # Associated record name. Used in function names
76
+ attr_reader :record_name
53
77
 
54
78
  # Action objects
55
79
  def insert = @actions["insert"]
@@ -60,13 +84,15 @@ module MkAcl
60
84
  # Hash from action name to action object
61
85
  attr_reader :actions
62
86
 
63
- def initialize(spec, name, domain)
87
+ def initialize(spec, name, domain, parent_name, acl)
64
88
  @spec = spec
65
- @parents = []
89
+ @references = {}
66
90
  @name = name.to_s
67
- @uid = "#{@spec.app_schema}.#{@name}"
91
+ @uid = "#{app_schema}.#{@name}"
68
92
  @record_name = Prick::Inflector.singularize(@name)
93
+ @parent_name = parent_name
69
94
  @domain = domain
95
+ @acl = acl
70
96
  @actions = {}
71
97
  @spec.send :attach_table, self
72
98
  for action_name in %w(insert select update delete)
@@ -79,12 +105,19 @@ module MkAcl
79
105
 
80
106
  def dump
81
107
  puts "#{name}:"
82
- indent {
108
+ indent {
83
109
  puts "domain: #{domain}" if domain
84
- puts "parents: #{parents.inspect}"
110
+ puts "parent: #{parent}" if parent
111
+ puts "references: [#{references.values.map(&:first).map(&:name).join(' ')}]"
85
112
  for action_name in %w(insert select update delete)
86
113
  actions[action_name]&.dump
87
114
  end
115
+ case acl
116
+ when false; puts "acl: false"
117
+ when true;
118
+ puts "acl:"
119
+ indent { puts acl }
120
+ end
88
121
  }
89
122
  end
90
123
 
@@ -106,8 +139,6 @@ module MkAcl
106
139
  @table.send :attach_action, self
107
140
  end
108
141
 
109
- def fields() @include + @exclude.map { "-#{_1}" } end
110
-
111
142
  def to_s() name end
112
143
 
113
144
  def dump
@@ -116,7 +147,7 @@ module MkAcl
116
147
  else
117
148
  puts name
118
149
  indent {
119
- for rule in rules
150
+ for rule in rules.sort_by(&:ordinal)
120
151
  print "- "
121
152
  indent(bol: false) { rule.dump }
122
153
  end
@@ -131,33 +162,40 @@ module MkAcl
131
162
  end
132
163
 
133
164
  class Rule
165
+ using String::Text
166
+
134
167
  attr_reader :action
135
168
  forward_to :action, :table, :name
136
- attr_reader :index
137
169
  attr_accessor :roles
138
- attr_accessor :using # Goes into the postgres policy
139
- attr_accessor :check # Goes into the postgres trigger
140
- attr_accessor :include
141
- attr_accessor :exclude
170
+ attr_accessor :filter # Goes into the postgres policy
171
+ attr_accessor :assert # Goes into the postgres trigger
172
+ attr_accessor :fields # Only used for insert and update
173
+ attr_accessor :tables # Only used for attach
174
+ attr_reader :ordinal
142
175
 
176
+ # admin, internal, etc.
143
177
  def auth_roles() @auth_roles ||= roles.select { _1 == _1.downcase } end
178
+
179
+ # KON, AKK, etc.
144
180
  def case_roles() @case_roles ||= roles.select { _1 == _1.upcase } end
145
181
 
146
- def initialize(action, index)
182
+ def initialize(action, ordinal)
147
183
  @action = action
148
- @index = index
149
- @roles, @check, @include, @exclude = [], nil, [], []
184
+ @ordinal = ordinal
185
+ @roles = []
186
+ @fields = []
187
+ @tables = []
188
+
150
189
  action.send :attach_rule, self
151
190
  end
152
191
 
153
- def fields() @include + @exclude.map { "-#{_1}" } end
154
-
155
192
  def dump
156
- puts "index: #{index}"
157
- puts "roles: #{roles.join(' ')}"
158
- puts "check: #{check}" if check
159
- puts "include: #{include.join(' ')}" if !include.empty?
160
- puts "exclude: #{exclude.join(' ')}" if !exclude.empty?
193
+ puts "roles: [#{roles.join(' ')}]"
194
+ puts "filter: #{filter}" if filter
195
+ puts "assert: #{assert}" if assert
196
+ puts "fields: [#{fields.join(' ')}]" if !fields.empty?
197
+ puts "tables: [#{tables.join(' ')}]" if !tables.empty?
198
+ puts "ordinal: #{ordinal}"
161
199
  end
162
200
  end
163
201
  end
@@ -1,7 +1,7 @@
1
1
 
2
2
  require 'pg_conn'
3
3
  require 'indented_io'
4
- require 'string-text'
4
+ require 'string-text'; using String::Text
5
5
  require 'forward_to'; include ForwardTo
6
6
  require 'constrain'; include Constrain
7
7
  require 'prick-inflector'
@@ -12,6 +12,7 @@ module MkAcl
12
12
  DOMAINS = %w(case event visit)
13
13
  DOMAIN_TABLES = DOMAINS.map { "#{_1}s" }
14
14
 
15
+ # TODO Read from database (or maybe not)
15
16
  CASE_ROLES = %w(LA TA KON AKK RLA CLA CTA)
16
17
  EVENT_ROLES = %w(ELA ETA)
17
18
  VISIT_ROLES = %w(VLA VTA)
@@ -19,6 +20,7 @@ module MkAcl
19
20
  ROLES = CASE_ROLES + EVENT_ROLES + VISIT_ROLES
20
21
  end
21
22
 
23
+ require_relative 'mkacl/simple_symtab.rb'
22
24
  require_relative 'mkacl/spec.rb'
23
25
  require_relative 'mkacl/parser.rb'
24
26
  require_relative 'mkacl/analyzer.rb'