macadmin 0.0.4
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 +7 -0
- data/.gitignore +17 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +163 -0
- data/Rakefile +23 -0
- data/ext/macadmin/password/crypto.c +89 -0
- data/ext/macadmin/password/extconf.rb +3 -0
- data/lib/macadmin.rb +22 -0
- data/lib/macadmin/common.rb +80 -0
- data/lib/macadmin/dslocal.rb +252 -0
- data/lib/macadmin/dslocal/computer.rb +22 -0
- data/lib/macadmin/dslocal/computergroup.rb +19 -0
- data/lib/macadmin/dslocal/dslocalnode.rb +281 -0
- data/lib/macadmin/dslocal/group.rb +82 -0
- data/lib/macadmin/dslocal/user.rb +113 -0
- data/lib/macadmin/mcx.rb +227 -0
- data/lib/macadmin/password.rb +72 -0
- data/lib/macadmin/shadowhash.rb +297 -0
- data/lib/macadmin/version.rb +8 -0
- data/macadmin.gemspec +35 -0
- data/spec/common_spec.rb +49 -0
- data/spec/computer_spec.rb +133 -0
- data/spec/computergroup_spec.rb +274 -0
- data/spec/dslocal_spec.rb +179 -0
- data/spec/dslocalnode_spec.rb +343 -0
- data/spec/group_spec.rb +275 -0
- data/spec/macadmin_spec.rb +13 -0
- data/spec/mcx_spec.rb +145 -0
- data/spec/password_spec.rb +40 -0
- data/spec/shadowhash_spec.rb +309 -0
- data/spec/spec_helper.rb +9 -0
- data/spec/user_spec.rb +248 -0
- metadata +218 -0
@@ -0,0 +1,82 @@
|
|
1
|
+
module MacAdmin
|
2
|
+
|
3
|
+
# Group
|
4
|
+
# - creates and manages Mac OS X User Groups
|
5
|
+
# - params: :name, :realname, :gid
|
6
|
+
class Group < DSLocalRecord
|
7
|
+
|
8
|
+
MIN_GID = 501
|
9
|
+
|
10
|
+
def initialize(args)
|
11
|
+
@member_class = User unless defined? @member_class
|
12
|
+
@group_class = Group unless defined? @group_class
|
13
|
+
super args
|
14
|
+
end
|
15
|
+
|
16
|
+
# Examine the object's users array for "member"
|
17
|
+
# - single param: the name of a user (String)
|
18
|
+
def has_user?(member)
|
19
|
+
self[:users].member? member
|
20
|
+
end
|
21
|
+
|
22
|
+
# Add a member to the object's users array
|
23
|
+
# - single param: the name of a user (String)
|
24
|
+
def add_user(member)
|
25
|
+
user = @member_class.new :name => member, :node => self.node
|
26
|
+
raise unless user.exists?
|
27
|
+
self[:users] << member
|
28
|
+
self[:users].uniq!
|
29
|
+
end
|
30
|
+
|
31
|
+
# Remove a member from the object's users array
|
32
|
+
# - single param: the name of a user (String)
|
33
|
+
def rm_user(member)
|
34
|
+
self[:users].delete member
|
35
|
+
end
|
36
|
+
|
37
|
+
# Examine the object's groupmembers array for "member"
|
38
|
+
# - single param: the name of a group (String)
|
39
|
+
def has_groupmember?(member)
|
40
|
+
group = @group_class.new :name => member, :node => self.node
|
41
|
+
return false unless group.exists?
|
42
|
+
self[:groupmembers].member? group.generateduid.first
|
43
|
+
end
|
44
|
+
|
45
|
+
# Add a member to the object's groupmembers array
|
46
|
+
# - single param: the name of a group (String)
|
47
|
+
def add_groupmember(member)
|
48
|
+
group = @group_class.new :name => member, :node => self.node
|
49
|
+
raise unless group.exists?
|
50
|
+
self[:groupmembers] << group.generateduid.first
|
51
|
+
self[:groupmembers].uniq!
|
52
|
+
end
|
53
|
+
|
54
|
+
# Remove a member from the object's groupmembers array
|
55
|
+
# - single param: the name of a group (String)
|
56
|
+
def rm_groupmember(member)
|
57
|
+
group = @group_class.new :name => member, :node => self.node
|
58
|
+
return nil unless group.exists?
|
59
|
+
self[:groupmembers].delete group.generateduid.first
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
# Handle required but unspecified record attributes
|
65
|
+
# - generates missing attributes
|
66
|
+
# - changes are merged into the composite record
|
67
|
+
def defaults(data)
|
68
|
+
records = all_records(@node)
|
69
|
+
next_gid = next_id(MIN_GID, get_all_attribs_of_type(:gid, records))
|
70
|
+
defaults = {
|
71
|
+
'realname' => ["#{data['name'].first.capitalize}"],
|
72
|
+
'gid' => ["#{next_gid}"],
|
73
|
+
'passwd' => ['*'],
|
74
|
+
'groupmembers' => [],
|
75
|
+
'users' => [],
|
76
|
+
}
|
77
|
+
super defaults.merge(data)
|
78
|
+
end
|
79
|
+
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
@@ -0,0 +1,113 @@
|
|
1
|
+
module MacAdmin
|
2
|
+
|
3
|
+
# User
|
4
|
+
# - creates and manages Mac OS X User accounts
|
5
|
+
# - params: :name, :realname, :password, :uid, :gid, :shell, :home, :comment
|
6
|
+
class User < DSLocalRecord
|
7
|
+
|
8
|
+
MIN_UID = 501
|
9
|
+
|
10
|
+
# Override parent initialization
|
11
|
+
# - capture the password if it exists
|
12
|
+
# - if there's no password object param, try to get one
|
13
|
+
# - shoehorn the password into the User record
|
14
|
+
def initialize(args)
|
15
|
+
if args.respond_to?(:keys)
|
16
|
+
@password = args.delete(:password)
|
17
|
+
end
|
18
|
+
super(args)
|
19
|
+
if @password
|
20
|
+
self.send(:password=)
|
21
|
+
else
|
22
|
+
@password = ShadowHash.create_from_user_record(self)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Generic setter
|
27
|
+
# - Accepts a ShadowHash object
|
28
|
+
# - delegates the storage operation to the ShadowHash object itself
|
29
|
+
def password=(password = @password)
|
30
|
+
error = 'Argument was not a ShadowHash object'
|
31
|
+
unless password.nil? or password.respond_to? :password
|
32
|
+
raise ArgumentError.new(error)
|
33
|
+
end
|
34
|
+
@password = password
|
35
|
+
@password.send(:store, self) unless @password.nil?
|
36
|
+
end
|
37
|
+
|
38
|
+
# Generic getter
|
39
|
+
# - Returns Ruby Hash representation of the User's password
|
40
|
+
def password
|
41
|
+
return nil unless @password
|
42
|
+
@password.password
|
43
|
+
end
|
44
|
+
|
45
|
+
# Legacy user records are determined by SHA1 password type
|
46
|
+
# - returns boolean
|
47
|
+
def legacy?
|
48
|
+
@password.is_a? SaltedSHA1
|
49
|
+
end
|
50
|
+
|
51
|
+
# Does the specified resource already exist?
|
52
|
+
# - overrides parent method; required for Legacy users
|
53
|
+
# - checks the password if SHA1 and kicks up to parent
|
54
|
+
def exists?
|
55
|
+
if self.legacy?
|
56
|
+
password_on_disk = SaltedSHA1.create_from_shadowhash_file self.generateduid[0]
|
57
|
+
return false unless password_on_disk
|
58
|
+
return false unless password_on_disk.password.eql? @password.password
|
59
|
+
end
|
60
|
+
super
|
61
|
+
end
|
62
|
+
|
63
|
+
# Create the record
|
64
|
+
# - overrides parent method; required for Legacy users
|
65
|
+
# - creates the password if SHA1 and kicks up to parent
|
66
|
+
def create(file=@file)
|
67
|
+
if self.legacy?
|
68
|
+
return unless @password.send(:to_file, self)
|
69
|
+
end
|
70
|
+
super
|
71
|
+
end
|
72
|
+
|
73
|
+
# Delete the record
|
74
|
+
# - overrides parent method; required for Legacy users
|
75
|
+
# - destroys the password if SHA1 and kicks up to parent
|
76
|
+
def destroy(file=@file)
|
77
|
+
if self.legacy?
|
78
|
+
return unless @password.send(:rm_file, self)
|
79
|
+
end
|
80
|
+
super
|
81
|
+
end
|
82
|
+
|
83
|
+
# Return a Puppet style resource manifest
|
84
|
+
# - need to find a sensible way of doing this
|
85
|
+
# - need to move this method into the Parent class
|
86
|
+
def to_puppet
|
87
|
+
puts "not implemented"
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
# Handle required but unspecified record attributes
|
93
|
+
# - generates missing attributes
|
94
|
+
# - changes are merged into the composite record
|
95
|
+
def defaults(data)
|
96
|
+
records = all_records(@node)
|
97
|
+
next_uid = next_id(MIN_UID, get_all_attribs_of_type(:uid, records))
|
98
|
+
defaults = {
|
99
|
+
'realname' => ["#{data['name'].first}"],
|
100
|
+
'uid' => ["#{next_uid}"],
|
101
|
+
'home' => ["/Users/#{data['name'].first}"],
|
102
|
+
'shell' => ['/bin/bash'],
|
103
|
+
'gid' => ['20'],
|
104
|
+
'passwd' => ['********'],
|
105
|
+
'comment' => [''],
|
106
|
+
}
|
107
|
+
super defaults.merge(data)
|
108
|
+
end
|
109
|
+
|
110
|
+
end
|
111
|
+
|
112
|
+
end
|
113
|
+
|
data/lib/macadmin/mcx.rb
ADDED
@@ -0,0 +1,227 @@
|
|
1
|
+
module MacAdmin
|
2
|
+
|
3
|
+
# MCX
|
4
|
+
# - methods and classes mixed into MacAdmin::DSLocalRecord for managing MCX policy
|
5
|
+
module MCX
|
6
|
+
|
7
|
+
# Policy
|
8
|
+
# - document format for mcx_export
|
9
|
+
class Policy
|
10
|
+
|
11
|
+
MANIFESTS = '/System/Library/CoreServices/ManagedClient.app/Contents/Resources'
|
12
|
+
|
13
|
+
def initialize(mcx_settings)
|
14
|
+
@documents = mcx_settings
|
15
|
+
@policy = process_documents(@documents)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Dump the document in a human-readable format
|
19
|
+
def to_plist
|
20
|
+
@policy.to_plist({:plist_format => CFPropertyList::List::FORMAT_XML, :formatted => true})
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
# Process each of the domain documents
|
26
|
+
def process_documents(documents)
|
27
|
+
documents.inject({}) do |dict, doc|
|
28
|
+
plist = CFPropertyList::List.new(:data => doc)
|
29
|
+
native = CFPropertyList.native_types(plist.value)
|
30
|
+
native['mcx_application_data'].inject({}) do |result, (domain, domain_dict)|
|
31
|
+
dict[domain] = process_domain(domain, domain_dict)
|
32
|
+
dict
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Process the domain preference document
|
38
|
+
# - flattens and reformats the domain document into constituent keys
|
39
|
+
def process_domain(domain, domain_dict)
|
40
|
+
manifest = "#{MANIFESTS}/#{domain}.manifest/Contents/Resources/#{domain}.manifest"
|
41
|
+
upk_subkeys = load_upk_subkeys(manifest)
|
42
|
+
domain_dict.inject({}) do |result, (enforcement, enforcement_array)|
|
43
|
+
enforcement_array.inject({}) do |hash, dict|
|
44
|
+
state = dict.include?('mcx_data_timestamp') ? 'once' : 'often'
|
45
|
+
state = 'always' if enforcement.eql? 'Forced'
|
46
|
+
dict['mcx_preference_settings'].each do |name, value|
|
47
|
+
result[name] = { 'state' => state, 'value' => value }
|
48
|
+
if upk_subkeys
|
49
|
+
upk = get_upk_info(name, state, upk_subkeys)
|
50
|
+
result[name]['upk'] = get_upk_info(name, state, upk_subkeys) if upk
|
51
|
+
end
|
52
|
+
result
|
53
|
+
end
|
54
|
+
end
|
55
|
+
result
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# Load the domain's preference manifest and return the UPK subkeys
|
60
|
+
# - returns Array
|
61
|
+
def load_upk_subkeys(manifest)
|
62
|
+
upk_subkeys = []
|
63
|
+
if File.exists? manifest
|
64
|
+
plist = CFPropertyList::List.new(:file => manifest)
|
65
|
+
native = CFPropertyList.native_types(plist.value)
|
66
|
+
upk_subkeys = native['pfm_subkeys'].select do |dict|
|
67
|
+
if dict['pfm_type'].eql? 'union policy'
|
68
|
+
dict
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
upk_subkeys
|
73
|
+
end
|
74
|
+
|
75
|
+
# Determine the most appropriate UPK keys for the given preference
|
76
|
+
# - returns Hash
|
77
|
+
def get_upk_info(name, state, upk_subkeys)
|
78
|
+
info = nil
|
79
|
+
if upk_subkeys
|
80
|
+
results = upk_subkeys.inject({}) do |results, dict|
|
81
|
+
if dict['pfm_upk_input_keys'].include? name
|
82
|
+
results[dict['pfm_upk_output_name']] = dict
|
83
|
+
end
|
84
|
+
results
|
85
|
+
end
|
86
|
+
if results
|
87
|
+
if results.size > 1
|
88
|
+
if state.eql? 'always'
|
89
|
+
results = results.select { |k,v| v if k =~ /-managed\z/ }
|
90
|
+
info = results.flatten.last
|
91
|
+
else
|
92
|
+
results = results.select { |k,v| v if k =~ /\Auser\z/ }
|
93
|
+
info = results.flatten.last
|
94
|
+
end
|
95
|
+
else
|
96
|
+
info = results.first
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
if info
|
101
|
+
upk = { 'mcx_input_key_names' => info['pfm_upk_input_keys'],
|
102
|
+
'mcx_output_key_name' => info['pfm_upk_output_name'],
|
103
|
+
'mcx_remove_duplicates' => info['pfm_remove_duplicates']
|
104
|
+
}
|
105
|
+
return upk
|
106
|
+
end
|
107
|
+
nil
|
108
|
+
end
|
109
|
+
|
110
|
+
end
|
111
|
+
|
112
|
+
# EmbeddedDocument
|
113
|
+
# - domain level MCX document suitable for storage in the record's mcx_settings array
|
114
|
+
class EmbeddedDocument
|
115
|
+
|
116
|
+
attr_reader :document
|
117
|
+
|
118
|
+
def initialize(domain, content)
|
119
|
+
@document = { 'mcx_application_data' => {} }
|
120
|
+
@domain = domain
|
121
|
+
@content = process(content)
|
122
|
+
end
|
123
|
+
|
124
|
+
def formatted
|
125
|
+
@document.to_plist({:plist_format => CFPropertyList::List::FORMAT_XML, :formatted => true})
|
126
|
+
end
|
127
|
+
|
128
|
+
def escaped
|
129
|
+
CGI.escapeHTML @document.to_plist({:plist_format => CFPropertyList::List::FORMAT_XML, :formatted => true})
|
130
|
+
end
|
131
|
+
|
132
|
+
private
|
133
|
+
|
134
|
+
def process(content)
|
135
|
+
@document['mcx_application_data'][@domain] = {}
|
136
|
+
content.each do |pref_name, pref_dict|
|
137
|
+
state = pref_dict['state']
|
138
|
+
enforcement = (state.eql?('always') ? 'Forced' : 'Set-Once')
|
139
|
+
@document['mcx_application_data'][@domain][enforcement] ||= [{}]
|
140
|
+
if pref_dict['upk']
|
141
|
+
@document['mcx_application_data'][@domain][enforcement][0]['mcx_union_policy_keys'] ||= []
|
142
|
+
@document['mcx_application_data'][@domain][enforcement][0]['mcx_union_policy_keys'] << pref_dict['upk']
|
143
|
+
end
|
144
|
+
@document['mcx_application_data'][@domain][enforcement][0]['mcx_preference_settings'] ||= {}
|
145
|
+
@document['mcx_application_data'][@domain][enforcement][0]['mcx_preference_settings'][pref_name] = pref_dict['value']
|
146
|
+
if state.eql? 'once'
|
147
|
+
@document['mcx_application_data'][@domain][enforcement][0]['mcx_data_timestamp'] = CFPropertyList::CFDate.parse_date(Time.now.utc.xmlschema)
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
end
|
153
|
+
|
154
|
+
# Settings
|
155
|
+
# - class representing the structure of the mcx_settings array
|
156
|
+
class Settings
|
157
|
+
|
158
|
+
attr_reader :domains
|
159
|
+
|
160
|
+
XML_TAG = '^<\?xml\sversion="1\.0"\sencoding="UTF-8"\?>'
|
161
|
+
|
162
|
+
class << self
|
163
|
+
def init_with_file(path)
|
164
|
+
content = load_plist(path)
|
165
|
+
self.new(content)
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
def initialize(content, type=:data)
|
170
|
+
type = :file unless content =~ /#{XML_TAG}/
|
171
|
+
plist = CFPropertyList::List.new(type => content)
|
172
|
+
native = CFPropertyList.native_types(plist.value)
|
173
|
+
@domains = []
|
174
|
+
@content = process(native)
|
175
|
+
end
|
176
|
+
|
177
|
+
private
|
178
|
+
|
179
|
+
def process(content)
|
180
|
+
content.each do |domain_name, domain_dict|
|
181
|
+
doc = EmbeddedDocument.new domain_name, domain_dict
|
182
|
+
@domains << doc.formatted
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
end
|
187
|
+
|
188
|
+
# Import MCX Content and apply it to the current object
|
189
|
+
# - accepts a single parameter: path to Plist file containing exported MCX policy or a string of XML content representing the MCX policy
|
190
|
+
# - re-formats the imported MCX for storage on the record and adds the two require attributes: mcx_flags and mcx_settings
|
191
|
+
# - current implmentation replaces policy wholesale (no append)
|
192
|
+
def mcx_import(content, append=false)
|
193
|
+
settings = Settings.new content
|
194
|
+
mcx_flags = { 'has_mcx_settings' => true }
|
195
|
+
mcx_flags = mcx_flags.to_plist({:plist_format => CFPropertyList::List::FORMAT_XML, :formatted => true})
|
196
|
+
self['mcx_flags'] = [CFPropertyList::Blob.new(mcx_flags)]
|
197
|
+
self['mcx_settings'] = settings.domains
|
198
|
+
end
|
199
|
+
alias :mcximport :mcx_import
|
200
|
+
|
201
|
+
# Export the MCX preferences for the record
|
202
|
+
def mcx_export
|
203
|
+
doc = Policy.new self['mcx_settings']
|
204
|
+
doc.to_plist
|
205
|
+
end
|
206
|
+
alias :mcxexport :mcx_export
|
207
|
+
|
208
|
+
# Pretty print the contents of the record's mcx_settings array
|
209
|
+
def pretty_mcx
|
210
|
+
self['mcx_settings'].collect { |doc| CGI.unescapeHTML doc }
|
211
|
+
end
|
212
|
+
|
213
|
+
# Does the object have any MCX policy?
|
214
|
+
def has_mcx?
|
215
|
+
self.has_key? 'mcx_settings' and self['mcx_settings'].is_a? Array and not self['mcx_settings'].empty?
|
216
|
+
end
|
217
|
+
|
218
|
+
# Remove all MCX policy from the object
|
219
|
+
def mcx_delete
|
220
|
+
self.delete('mcx_flags')
|
221
|
+
self.delete('mcx_settings')
|
222
|
+
end
|
223
|
+
alias :mcxdelete :mcx_delete
|
224
|
+
|
225
|
+
end
|
226
|
+
|
227
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
module MacAdmin
|
2
|
+
|
3
|
+
# Password
|
4
|
+
# - module containing methods for converting a plain String into Mac OS X password hash
|
5
|
+
module Password
|
6
|
+
|
7
|
+
require 'openssl'
|
8
|
+
require 'securerandom'
|
9
|
+
|
10
|
+
extend self
|
11
|
+
|
12
|
+
# Convert ASCII string to hex bytes
|
13
|
+
def convert_to_hex(string)
|
14
|
+
string.unpack('H*').first
|
15
|
+
end
|
16
|
+
|
17
|
+
# Convert hex string to CFBlob
|
18
|
+
def convert_to_blob(hex)
|
19
|
+
ascii = hex.scan(/../).collect { |byte| byte.hex.chr }.join
|
20
|
+
CFPropertyList::Blob.new(ascii)
|
21
|
+
end
|
22
|
+
|
23
|
+
# This method is only available in Mountain Lion or better
|
24
|
+
if MacAdmin::Common::MAC_OS_X_PRODUCT_VERSION > 10.7
|
25
|
+
|
26
|
+
require "macadmin/password/crypto"
|
27
|
+
|
28
|
+
# Creates a SaltedSHA512PBKDF2 password from String
|
29
|
+
# - single param: String
|
30
|
+
# - returns: SaltedSHA512PBKDF2
|
31
|
+
def salted_sha512_pbkdf2(password)
|
32
|
+
hash = salted_sha512_pbkdf2_from_string password
|
33
|
+
SaltedSHA512PBKDF2.new hash
|
34
|
+
end
|
35
|
+
|
36
|
+
end
|
37
|
+
|
38
|
+
# Creates a SaltedSHA512 password from String
|
39
|
+
# - single param: String
|
40
|
+
# - returns: SaltedSHA512
|
41
|
+
def salted_sha512(password)
|
42
|
+
salt = SecureRandom.random_bytes(4)
|
43
|
+
hash = Digest::SHA512.hexdigest(salt + password)
|
44
|
+
SaltedSHA512.new(convert_to_hex(salt) + hash)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Creates a SaltedSHA1 password from String
|
48
|
+
# - single param: String
|
49
|
+
# - returns: SaltedSHA1
|
50
|
+
def salted_sha1(password)
|
51
|
+
salt = SecureRandom.random_bytes(4)
|
52
|
+
hash = Digest::SHA1.hexdigest(salt + password)
|
53
|
+
SaltedSHA1.new((convert_to_hex(salt) + hash).upcase)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Create a platform appropriate password
|
57
|
+
# - single param: String
|
58
|
+
# - returns: SaltedSHA512PBKDF2 or SaltedSHA512 or SaltedSHA1 depending on platform
|
59
|
+
def apropos(password)
|
60
|
+
platform = MacAdmin::Common::MAC_OS_X_PRODUCT_VERSION
|
61
|
+
if platform >= 10.8
|
62
|
+
return salted_sha512_pbkdf2 password
|
63
|
+
elsif platform == 10.7
|
64
|
+
return salted_sha512 password
|
65
|
+
else
|
66
|
+
return salted_sha1 password
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
end
|
71
|
+
|
72
|
+
end
|