-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathmetadata.cr
More file actions
277 lines (230 loc) · 10.9 KB
/
metadata.cr
File metadata and controls
277 lines (230 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
require "promise"
require "./application"
module PlaceOS::Api
class Metadata < Application
include Utils::Permissions
base "/api/engine/v2/metadata"
# Scopes
###############################################################################################
before_action :can_read, only: [:history]
before_action :can_read_guest, only: [:show, :children_metadata]
before_action :can_write, only: [:update, :destroy]
# Callbacks
###############################################################################################
# Does the user making the request have permissions to modify the data
@[AC::Route::Filter(:before_action, only: [:destroy])]
def check_delete_permissions(
@[AC::Param::Info(name: "id", description: "the parent id of the metadata to be destroyed")]
parent_id : String,
)
return if user_support? || parent_id == user_token.id
check_access_level(parent_id, admin_required: true)
end
###############################################################################################
# Fetch metadata for a model
#
# Filter for a specific metadata by name via `name` param
@[AC::Route::GET("/:id")]
def show(
@[AC::Param::Info(name: "id", description: "the parent id of the metadata to be returned")]
parent_id : String,
@[AC::Param::Info(description: "the name of the metadata key", example: "config")]
name : String? = nil,
) : Hash(String, ::PlaceOS::Model::Metadata::Interface)
# Guest JWTs include the control system id that they have access to
if user_token.guest_scope?
raise Error::Forbidden.new unless name && guest_ids.includes?(parent_id)
end
::PlaceOS::Model::Metadata.build_metadata(parent_id, name)
end
record Children, zone : ::PlaceOS::Model::Zone, metadata : Hash(String, ::PlaceOS::Model::Metadata::Interface) do
include JSON::Serializable
def initialize(@zone, metadata_key : String?)
@metadata = ::PlaceOS::Model::Metadata.build_metadata(@zone, metadata_key)
end
end
# Fetch metadata for Zone children
#
# Filter for a specific metadata by name via `name` param.
# Includes the parent metadata by default via `include_parent` param.
# Filter zones by tags via `tags` param.
@[AC::Route::GET("/:id/children", converters: {tags: ConvertStringArray})]
def children_metadata(
@[AC::Param::Info(name: "id", description: "the parent id of the metadata to be returned")]
parent_id : String,
@[AC::Param::Info(description: "the parent metadata is included in the results by default", example: "false")]
include_parent : Bool = true,
@[AC::Param::Info(description: "filter for a particular metadata key", example: "config")]
name : String? = nil,
@[AC::Param::Info(description: "return zones with particular tags", example: "building,level")]
tags : Array(String)? = nil,
) : Array(Children)
# Guest JWTs include the control system id that they have access to
if user_token.guest_scope?
raise Error::Forbidden.new unless name && guest_ids.includes?(parent_id)
end
Log.context.set(zone_id: parent_id)
current_zone = ::PlaceOS::Model::Zone.find!(parent_id)
# Get children zones with optional tag filtering
children_zones = if (filter_tags = tags) && !filter_tags.empty?
# Start with all children zones
zones = current_zone.children.to_a
# Filter zones that contain any of the filter tags
zones.select do |zone|
zone_tags = zone.tags || Set(String).new
filter_tags.any? { |tag| zone_tags.includes?(tag) }
end
else
current_zone.children.all.to_a
end
children_zones.compact_map do |zone|
next unless include_parent || zone.id != parent_id
children_obj = Children.new(zone, name)
# If name filter is provided and tags filter is also provided,
# only include zones that have metadata matching the name filter
if name && tags && !tags.empty?
next if children_obj.metadata.empty?
end
children_obj
end
end
# update only the keys provided on the selected metadata
# udpates are signalled on the `placeos/metadata/changed` channel
@[AC::Route::PATCH("/:id", body: :meta)]
def merge(
@[AC::Param::Info(name: "id", description: "the parent id of the metadata to be updated")]
parent_id : String,
meta : ::PlaceOS::Model::Metadata::Interface,
) : ::PlaceOS::Model::Metadata::Interface
mutate(parent_id, meta, merge: true)
end
# replace the metadata with this new metadata
# udpates are signalled on the `placeos/metadata/changed` channel
@[AC::Route::PUT("/:id", body: :meta)]
def update(
@[AC::Param::Info(name: "id", description: "the parent id of the metadata to be replaced")]
parent_id : String,
meta : ::PlaceOS::Model::Metadata::Interface,
) : ::PlaceOS::Model::Metadata::Interface
mutate(parent_id, meta, merge: false)
end
UNSCOPED_SIGNAL_CHANNEL = "placeos/metadata/changed"
SCOPED_SIGNAL_CHANNEL = "placeos/%s/metadata/changed"
protected def self.signal_metadata(authority : String, action : Symbol, metadata) : Nil
payload = {
action: action,
metadata: metadata,
}.to_json
Log.info { "signalling #{UNSCOPED_SIGNAL_CHANNEL} with #{payload.bytesize} bytes" }
::PlaceOS::Driver::RedisStorage.with_redis &.publish(UNSCOPED_SIGNAL_CHANNEL, payload)
signal_channel = sprintf(SCOPED_SIGNAL_CHANNEL, authority)
Log.info { "signalling #{signal_channel} with #{payload.bytesize} bytes" }
::PlaceOS::Driver::RedisStorage.with_redis &.publish(signal_channel, payload)
end
# Find (otherwise create) then update (or patch) the Metadata.
protected def mutate(parent_id : String, metadata : ::PlaceOS::Model::Metadata::Interface, merge : Bool)
# A name is required to lookup the metadata
raise Error::ModelValidation.new({Error::Field.new(:name, "Name must not be empty")}) unless metadata.name.presence
metadata = create_or_update(parent_id, metadata, merge: merge)
raise Error::ModelValidation.new(metadata.errors) unless metadata.save
metadata
payload = metadata.interface
spawn { self.class.signal_metadata(current_authority.not_nil!.id.to_s, :update, payload) }
payload
end
# remove a metadata entry from the database
@[AC::Route::DELETE("/:id", status_code: HTTP::Status::ACCEPTED)]
def destroy(
@[AC::Param::Info(name: "id", description: "the parent id of the metadata to be returned")]
parent_id : String,
@[AC::Param::Info(name: "name", description: "the name of the metadata key", example: "config")]
metadata_name : String,
) : Nil
::PlaceOS::Model::Metadata.for(parent_id, metadata_name).each &.destroy
spawn do
if metadata_name.empty?
self.class.signal_metadata(current_authority.not_nil!.id.to_s, :destroy_all, {
parent_id: parent_id,
})
else
self.class.signal_metadata(current_authority.not_nil!.id.to_s, :destroy, {
parent_id: parent_id,
name: metadata_name,
})
end
end
end
# Returns the version history for a Settings model
@[AC::Route::GET("/:id/history")]
def history(
@[AC::Param::Info(name: "id", description: "the parent id of the metadata to be returned")]
parent_id : String,
@[AC::Param::Info(description: "the name of the metadata key", example: "config")]
name : String? = nil,
@[AC::Param::Info(description: "the maximum number of results to return", example: "10000")]
limit : Int32 = 100,
@[AC::Param::Info(description: "the starting offset of the result set. Used to implement pagination")]
offset : Int32 = 0,
) : Hash(String, Array(::PlaceOS::Model::Metadata::Interface))
history = ::PlaceOS::Model::Metadata.build_history(parent_id, name, offset: offset, limit: limit)
total = ::PlaceOS::Model::Metadata.for(parent_id, name).max_of?(&.history_count) || 0
range_start = offset
range_end = (history.max_of?(&.last.size) || 0) + range_start
response.headers["X-Total-Count"] = total.to_s
response.headers["Content-Range"] = "metadata #{range_start}-#{range_end}/#{total}"
# Set link
if range_end < total
params["offset"] = (range_end + 1).to_s
params["limit"] = limit.to_s
path = File.join(base_route, "/#{parent_id}/history")
response.headers["Link"] = %(<#{path}?#{query_params}>; rel="next")
end
history
end
# Helpers
###########################################################################
def create_or_update(parent_id : String, interface : ::PlaceOS::Model::Metadata::Interface, merge : Bool) : ::PlaceOS::Model::Metadata
if metadata = ::PlaceOS::Model::Metadata.for(parent_id, interface.name).first?
# Check if the current user has access
check_access_level(parent_id) unless metadata.user_can_update?(user_token)
metadata.assign_from_interface(user_token, interface, merge)
else
# When creating a new metadata, must be at least a support user or own the metadata
check_access_level(parent_id) unless ::PlaceOS::Model::Metadata.user_can_create?(parent_id, user_token)
# Create a new Metadata
::PlaceOS::Model::Metadata.from_interface(interface).tap do |model|
# Set `parent_id` in create
model.parent_id = parent_id
end
end.tap do |model|
model.modified_by = current_user
end
end
# Fetch zones for system the current user has a role for
def guest_ids
sys_id = user_token.user.roles.last
::PlaceOS::Model::ControlSystem.find!(sys_id).zones + [sys_id]
end
def check_access_level(zone_id : String, admin_required : Bool = false)
# ensure this is a zone_id we're checking
raise Error::Forbidden.new unless zone_id.starts_with? "zone-"
# find the org zone
authority = current_authority.as(::PlaceOS::Model::Authority)
org_zone_id = authority.config["org_zone"]?.try(&.as_s?)
raise Error::Forbidden.new unless org_zone_id
# check that the permissions apply to this zone
current_zone = ::PlaceOS::Model::Zone.find!(zone_id)
root_zone_id = current_zone.root_zone_id
if root_zone_id == org_zone_id
zones = [org_zone_id, zone_id].uniq!
access = check_access(current_user.groups, zones)
if admin_required
return if access.admin?
else
return if access.can_manage?
end
end
raise Error::Forbidden.new
end
end
end