def test_add_two_rules_and_get(self): id1 = uuidutils.generate_uuid() rule1_str = "p(x) :- q(x)" id2 = uuidutils.generate_uuid() rule2_str = "z(x) :- q(x)" policy_name = "classification" comment = "None" db_policy_rules.add_policy_rule(id=id1, policy_name=policy_name, rule=rule1_str, comment=comment) db_policy_rules.add_policy_rule(id=id2, policy_name=policy_name, rule=rule2_str, comment=comment) rules = db_policy_rules.get_policy_rules(policy_name) self.assertEqual(len(rules), 2) self.assertEqual(id1, rules[0].id) self.assertEqual(policy_name, rules[0].policy_name) self.assertEqual(rule1_str, rules[0].rule) self.assertEqual(comment, rules[0].comment) self.assertEqual(id2, rules[1].id) self.assertEqual(policy_name, rules[1].policy_name) self.assertEqual(rule2_str, rules[1].rule) self.assertEqual(comment, rules[1].comment) self.assertEqual(len(db_policy_rules.get_policy_rules()), 2)
def get_items(self, params, context=None): """Get items in model. Args: params: A dict-like object containing parameters from the request query string and body. context: Key-values providing frame of reference of request Returns: A dict containing at least a 'results' key whose value is a list of items in the model. Additional keys set in the dict will also be rendered for the user. """ policy_name = self.policy_name(context) rules = db_policy_rules.get_policy_rules(policy_name) results = [] for rule in rules: d = {'rule': rule.rule, 'id': rule.id, 'comment': rule.comment} results.append(d) return {'results': results}
def get_items(self, params, context=None): """Get items in model. Args: params: A dict-like object containing parameters from the request query string and body. context: Key-values providing frame of reference of request Returns: A dict containing at least a 'results' key whose value is a list of items in the model. Additional keys set in the dict will also be rendered for the user. """ policy_name = self.policy_name(context) rules = db_policy_rules.get_policy_rules(policy_name) results = [] for rule in rules: d = { 'rule': rule.rule, 'id': rule.id, 'comment': rule.comment, 'name': rule.name } results.append(d) return {'results': results}
def create(rootdir, statedir, config_override=None): """Get Congress up and running when src is installed in rootdir. i.e. ROOTDIR=/path/to/congress/congress. CONFIG_OVERRIDE is a dictionary of dictionaries with configuration values that overrides those provided in CONFIG_FILE. The top-level dictionary has keys for the CONFIG_FILE sections, and the second-level dictionaries store values for that section. """ LOG.debug("Starting Congress with rootdir=%s, statedir=%s, " "config_override=%s", rootdir, statedir, config_override) # create message bus cage = d6cage.d6Cage() # read in datasource configurations cage.config = config_override or {} # path to congress source dir src_path = os.path.join(rootdir, "congress") # add policy engine engine_path = os.path.join(src_path, "policy_engines/agnostic.py") LOG.info("main::start() engine_path: %s", engine_path) cage.loadModule("PolicyEngine", engine_path) cage.createservice( name="engine", moduleName="PolicyEngine", description="Policy Engine (DseRuntime instance)", args={'d6cage': cage, 'rootdir': src_path}) engine = cage.service_object('engine') if statedir is not None: engine.load_dir(statedir) engine.initialize_table_subscriptions() engine.debug_mode() # should take this out for production # add policy api api_path = os.path.join(src_path, "api/policy_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-policy", api_path) cage.createservice( name="api-policy", moduleName="API-policy", description="API-policy DSE instance", args={'policy_engine': engine}) # add rule api api_path = os.path.join(src_path, "api/rule_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-rule", api_path) cage.createservice( name="api-rule", moduleName="API-rule", description="API-rule DSE instance", args={'policy_engine': engine}) # add table api api_path = os.path.join(src_path, "api/table_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-table", api_path) cage.createservice( name="api-table", moduleName="API-table", description="API-table DSE instance", args={'policy_engine': engine}) # add row api api_path = os.path.join(src_path, "api/row_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-row", api_path) cage.createservice( name="api-row", moduleName="API-row", description="API-row DSE instance", args={'policy_engine': engine}) # add status api api_path = os.path.join(src_path, "api/status_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-status", api_path) cage.createservice( name="api-status", moduleName="API-status", description="API-status DSE instance", args={'policy_engine': engine}) # add schema api api_path = os.path.join(src_path, "api/schema_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-schema", api_path) cage.createservice( name="api-schema", moduleName="API-schema", description="API-schema DSE instance", args={'policy_engine': engine}) # add datasource/config api api_path = os.path.join(src_path, "api/datasource_config_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-config", api_path) cage.createservice( name="api-config", moduleName="API-config", description="API-config DSE instance", args={'policy_engine': engine}) # add path for system/datasource-drivers api_path = os.path.join(src_path, "api/system/driver_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-system", api_path) cage.createservice( name="api-system", moduleName="API-system", description="API-system DSE instance", args={'policy_engine': engine}) # Load policies from database for policy in db_policy_rules.get_policies(): engine.create_policy( policy.name, abbr=policy.abbreviation, kind=policy.kind) # if this is the first time we are running Congress, need # to create the default theories (which cannot be deleted) api_policy = cage.service_object('api-policy') engine.DEFAULT_THEORY = 'classification' engine.builtin_policy_names.add(engine.DEFAULT_THEORY) try: api_policy.add_item({'name': engine.DEFAULT_THEORY, 'description': 'default policy'}, {}) except KeyError: pass engine.ACTION_THEORY = 'action' engine.builtin_policy_names.add(engine.ACTION_THEORY) try: api_policy.add_item({'kind': ACTION_POLICY_TYPE, 'name': engine.ACTION_THEORY, 'description': 'default action policy'}, {}) except KeyError: pass # have policy-engine subscribe to api calls # TODO(thinrichs): either have API publish everything to DSE bus and # have policy engine subscribe to all those messages # OR have API interact with individual components directly # and change all tests so that the policy engine does not need to be # subscribed to 'policy-update' engine.subscribe('api-rule', 'policy-update', callback=engine.receive_policy_update) # spin up all the configured services, if we have configured them datasource_mgr = datasource_manager.DataSourceManager drivers = datasource_mgr.get_datasources() # Setup cage.config as it previously done when it was loaded # from disk. FIXME(arosen) later! for driver in drivers: if not driver['enabled']: LOG.info("module %s not enabled, skip loading", driver['name']) continue driver_info = datasource_mgr.get_driver_info(driver['driver']) engine.create_policy(driver['name']) try: cage.createservice(name=driver['name'], moduleName=driver_info['module'], args=driver['config'], module_driver=True, type_='datasource_driver', id_=driver['id']) except d6cage.DataServiceError: # FIXME(arosen): If createservice raises congress-server # dies here. So we catch this exception so the server does # not die. We need to refactor the dse code so it just # keeps retrying the driver gracefully... continue service = cage.service_object(driver['name']) engine.set_schema(driver['name'], service.get_schema()) # Insert rules. Needs to be done after datasources are loaded # so that we can compile away column references at read time. # If datasources loaded after this, we don't have schemas. rules = db_policy_rules.get_policy_rules() for rule in rules: parsed_rule = engine.parse1(rule.rule) cage.service_object('api-rule').change_rule( parsed_rule, {'policy_id': rule.policy_name}) # Start datasource synchronizer after explicitly starting the # datasources, because the explicit call to create a datasource # will crash if the synchronizer creates the datasource first. synchronizer_path = os.path.join(src_path, "synchronizer.py") LOG.info("main::start() synchronizer: %s", synchronizer_path) cage.loadModule("Synchronizer", synchronizer_path) cage.createservice( name="synchronizer", moduleName="Synchronizer", description="DB synchronizer instance", args={'poll_time': cfg.CONF.datasource_sync_period}) synchronizer = cage.service_object('synchronizer') # add datasource api api_path = os.path.join(src_path, "api/datasource_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-datasource", api_path) cage.createservice( name="api-datasource", moduleName="API-datasource", description="API-datasource DSE instance", args={'policy_engine': engine, 'synchronizer': synchronizer}) return cage
def synchronize_rules_nonlocking(self, db_session=None): LOG.debug("Synchronizing rules on node %s", self.node.node_id) try: # Read rules from DB. configured_rules = [] configured_facts = [] for r in db_policy_rules.get_policy_rules(session=db_session): if ':-' in r.rule: # if rule has body configured_rules.append({'rule': r.rule, 'id': r.id, 'comment': r.comment, 'name': r.name, 'policy_name': r.policy_name}) else: # head-only rule, ie., fact configured_facts.append( {'rule': self.engine.parse1(r.rule).pretty_str(), # note:parse to remove effect of extraneous formatting 'policy_name': r.policy_name}) # Read rules from engine policies = {n: self.engine.policy_object(n) for n in self.engine.policy_names()} active_policy_rules = [] active_policy_facts = [] for policy_name, policy in policies.items(): if policy.kind != base.DATASOURCE_POLICY_TYPE: for active_rule in policy.content(): # FIXME: This assumes r.original_str is None iff # r is a head-only rule (fact). This works in # non-recursive policy but not in recursive policies if active_rule.original_str is None: active_policy_facts.append( {'rule': str(active_rule.head), 'policy_name': policy_name}) else: active_policy_rules.append( {'rule': active_rule.original_str, 'id': active_rule.id, 'comment': active_rule.comment, 'name': active_rule.name, 'policy_name': policy_name}) # ALEX: the Rule object does not have fields like the rule-string # or id or comment. We can add those fields to the Rule object, # as long as we don't add them to the Fact because there are many # fact instances. If a user tries to create a lot of Rules, they # are probably doing something wrong and should use a datasource # driver instead. changes = [] # add configured rules for r in configured_rules: if r not in active_policy_rules: LOG.debug("adding rule %s", str(r)) parsed_rule = self.engine.parse1(r['rule']) parsed_rule.set_id(r['id']) parsed_rule.set_name(r['name']) parsed_rule.set_comment(r['comment']) parsed_rule.set_original_str(r['rule']) event = compile.Event(formula=parsed_rule, insert=True, target=r['policy_name']) changes.append(event) # add configured facts for r in configured_facts: if r not in active_policy_facts: LOG.debug("adding rule %s", str(r)) parsed_rule = self.engine.parse1(r['rule']) event = compile.Event(formula=parsed_rule, insert=True, target=r['policy_name']) changes.append(event) # remove active rules not configured for r in active_policy_rules: if r not in configured_rules: LOG.debug("removing rule %s", str(r)) parsed_rule = self.engine.parse1(r['rule']) parsed_rule.set_id(r['id']) parsed_rule.set_name(r['name']) parsed_rule.set_comment(r['comment']) parsed_rule.set_original_str(r['rule']) event = compile.Event(formula=parsed_rule, insert=False, target=r['policy_name']) changes.append(event) # remove active facts not configured for r in active_policy_facts: if r not in configured_facts: LOG.debug("removing rule %s", str(r)) parsed_rule = self.engine.parse1(r['rule']) event = compile.Event(formula=parsed_rule, insert=False, target=r['policy_name']) changes.append(event) permitted, changes = self.engine.process_policy_update(changes) LOG.info("synchronize_rules, permitted %d, made %d changes on " "node %s", permitted, len(changes), self.node.node_id) except Exception: LOG.exception("synchronizing rules failed")
def create(rootdir, statedir, config_file, config_override=None): """Get Congress up and running when src is installed in rootdir, i.e. ROOTDIR=/path/to/congress/congress. CONFIG_OVERRIDE is a dictionary of dictionaries with configuration values that overrides those provided in CONFIG_FILE. The top-level dictionary has keys for the CONFIG_FILE sections, and the second-level dictionaries store values for that section. """ LOG.debug("Starting Congress with rootdir=%s, statedir=%s, " "datasource_config=%s, config_override=%s", rootdir, statedir, config_file, config_override) # create message bus cage = d6cage.d6Cage() cage.system_service_names.add(cage.name) # read in datasource configurations cage.config = initialize_config(config_file, config_override) # path to congress source dir src_path = os.path.join(rootdir, "congress") # add policy engine engine_path = os.path.join(src_path, "policy/dsepolicy.py") LOG.info("main::start() engine_path: %s", engine_path) cage.loadModule("PolicyEngine", engine_path) cage.createservice( name="engine", moduleName="PolicyEngine", description="Policy Engine (DseRuntime instance)", args={'d6cage': cage, 'rootdir': src_path}) engine = cage.service_object('engine') if statedir is not None: engine.load_dir(statedir) engine.initialize_table_subscriptions() cage.system_service_names.add(engine.name) engine.debug_mode() # should take this out for production # add policy api # TODO(thinrichs): change to real API path. api_path = os.path.join(src_path, "api/policy_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-policy", api_path) cage.createservice( name="api-policy", moduleName="API-policy", description="API-policy DSE instance", args={'policy_engine': engine}) cage.system_service_names.add('api-policy') # add rule api api_path = os.path.join(src_path, "api/rule_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-rule", api_path) cage.createservice( name="api-rule", moduleName="API-rule", description="API-rule DSE instance", args={'policy_engine': engine}) cage.system_service_names.add('api-rule') # add table api api_path = os.path.join(src_path, "api/table_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-table", api_path) cage.createservice( name="api-table", moduleName="API-table", description="API-table DSE instance", args={'policy_engine': engine}) cage.system_service_names.add('api-table') # add row api api_path = os.path.join(src_path, "api/row_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-row", api_path) cage.createservice( name="api-row", moduleName="API-row", description="API-row DSE instance", args={'policy_engine': engine}) cage.system_service_names.add('api-row') # add datasource api api_path = os.path.join(src_path, "api/datasource_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-datasource", api_path) cage.createservice( name="api-datasource", moduleName="API-datasource", description="API-datasource DSE instance", args={'policy_engine': engine}) cage.system_service_names.add('api-datasource') # add status api api_path = os.path.join(src_path, "api/status_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-status", api_path) cage.createservice( name="api-status", moduleName="API-status", description="API-status DSE instance", args={'policy_engine': engine}) cage.system_service_names.add('api-status') # add schema api api_path = os.path.join(src_path, "api/schema_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-schema", api_path) cage.createservice( name="api-schema", moduleName="API-schema", description="API-schema DSE instance", args={'policy_engine': engine}) cage.system_service_names.add('api-schema') # have policy-engine subscribe to api calls # TODO(thinrichs): either have API publish everything to DSE bus and # have policy engine subscribe to all those messages # OR have API interact with individual components directly # and change all tests so that the policy engine does not need to be # subscribed to 'policy-update' engine.subscribe('api-rule', 'policy-update', callback=engine.receive_policy_update) # spin up all the configured services, if we have configured them if cage.config: for name in cage.config: if 'module' in cage.config[name]: load_data_service(name, cage.config[name], cage, src_path) # inform policy engine about schema service = cage.service_object(name) engine.set_schema(name, service.get_schema()) # populate rule api data, needs to be done after models are loaded. # FIXME(arosen): refactor how we're loading data and api. rules = db_policy_rules.get_policy_rules() for rule in rules: parsed_rule = compile.parse(rule.rule)[0] cage.services['api-rule']['object'].change_rule( parsed_rule, {'policy_id': rule.policy_name}) return cage
def create(rootdir, statedir, config_override=None): """Get Congress up and running when src is installed in rootdir. i.e. ROOTDIR=/path/to/congress/congress. CONFIG_OVERRIDE is a dictionary of dictionaries with configuration values that overrides those provided in CONFIG_FILE. The top-level dictionary has keys for the CONFIG_FILE sections, and the second-level dictionaries store values for that section. """ LOG.debug( "Starting Congress with rootdir=%s, statedir=%s, " "config_override=%s", rootdir, statedir, config_override) # create message bus cage = d6cage.d6Cage() # read in datasource configurations cage.config = config_override or {} # path to congress source dir src_path = os.path.join(rootdir, "congress") # add policy engine engine_path = os.path.join(src_path, "policy_engines/agnostic.py") LOG.info("main::start() engine_path: %s", engine_path) cage.loadModule("PolicyEngine", engine_path) cage.createservice(name="engine", moduleName="PolicyEngine", description="Policy Engine (DseRuntime instance)", args={ 'd6cage': cage, 'rootdir': src_path }) engine = cage.service_object('engine') if statedir is not None: engine.load_dir(statedir) engine.initialize_table_subscriptions() engine.debug_mode() # should take this out for production # add policy api api_path = os.path.join(src_path, "api/policy_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-policy", api_path) cage.createservice(name="api-policy", moduleName="API-policy", description="API-policy DSE instance", args={'policy_engine': engine}) # add rule api api_path = os.path.join(src_path, "api/rule_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-rule", api_path) cage.createservice(name="api-rule", moduleName="API-rule", description="API-rule DSE instance", args={'policy_engine': engine}) # add table api api_path = os.path.join(src_path, "api/table_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-table", api_path) cage.createservice(name="api-table", moduleName="API-table", description="API-table DSE instance", args={'policy_engine': engine}) # add row api api_path = os.path.join(src_path, "api/row_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-row", api_path) cage.createservice(name="api-row", moduleName="API-row", description="API-row DSE instance", args={'policy_engine': engine}) # add status api api_path = os.path.join(src_path, "api/status_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-status", api_path) cage.createservice(name="api-status", moduleName="API-status", description="API-status DSE instance", args={'policy_engine': engine}) # add schema api api_path = os.path.join(src_path, "api/schema_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-schema", api_path) cage.createservice(name="api-schema", moduleName="API-schema", description="API-schema DSE instance", args={'policy_engine': engine}) # add datasource/config api api_path = os.path.join(src_path, "api/datasource_config_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-config", api_path) cage.createservice(name="api-config", moduleName="API-config", description="API-config DSE instance", args={'policy_engine': engine}) # add path for system/datasource-drivers api_path = os.path.join(src_path, "api/system/driver_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-system", api_path) cage.createservice(name="api-system", moduleName="API-system", description="API-system DSE instance", args={'policy_engine': engine}) # Load policies from database for policy in db_policy_rules.get_policies(): engine.create_policy(policy.name, abbr=policy.abbreviation, kind=policy.kind) # if this is the first time we are running Congress, need # to create the default theories (which cannot be deleted) api_policy = cage.service_object('api-policy') engine.DEFAULT_THEORY = 'classification' engine.builtin_policy_names.add(engine.DEFAULT_THEORY) try: api_policy.add_item( { 'name': engine.DEFAULT_THEORY, 'description': 'default policy' }, {}) except KeyError: pass engine.ACTION_THEORY = 'action' engine.builtin_policy_names.add(engine.ACTION_THEORY) try: api_policy.add_item( { 'kind': ACTION_POLICY_TYPE, 'name': engine.ACTION_THEORY, 'description': 'default action policy' }, {}) except KeyError: pass # have policy-engine subscribe to api calls # TODO(thinrichs): either have API publish everything to DSE bus and # have policy engine subscribe to all those messages # OR have API interact with individual components directly # and change all tests so that the policy engine does not need to be # subscribed to 'policy-update' engine.subscribe('api-rule', 'policy-update', callback=engine.receive_policy_update) # spin up all the configured services, if we have configured them datasource_mgr = datasource_manager.DataSourceManager drivers = datasource_mgr.get_datasources() # Setup cage.config as it previously done when it was loaded # from disk. FIXME(arosen) later! for driver in drivers: if not driver['enabled']: LOG.info("module %s not enabled, skip loading", driver['name']) continue driver_info = datasource_mgr.get_driver_info(driver['driver']) engine.create_policy(driver['name']) try: cage.createservice(name=driver['name'], moduleName=driver_info['module'], args=driver['config'], module_driver=True, type_='datasource_driver', id_=driver['id']) except d6cage.DataServiceError: # FIXME(arosen): If createservice raises congress-server # dies here. So we catch this exception so the server does # not die. We need to refactor the dse code so it just # keeps retrying the driver gracefully... continue service = cage.service_object(driver['name']) engine.set_schema(driver['name'], service.get_schema()) # Insert rules. Needs to be done after datasources are loaded # so that we can compile away column references at read time. # If datasources loaded after this, we don't have schemas. rules = db_policy_rules.get_policy_rules() for rule in rules: parsed_rule = engine.parse1(rule.rule) cage.service_object('api-rule').change_rule( parsed_rule, {'policy_id': rule.policy_name}) # Start datasource synchronizer after explicitly starting the # datasources, because the explicit call to create a datasource # will crash if the synchronizer creates the datasource first. synchronizer_path = os.path.join(src_path, "synchronizer.py") LOG.info("main::start() synchronizer: %s", synchronizer_path) cage.loadModule("Synchronizer", synchronizer_path) cage.createservice(name="synchronizer", moduleName="Synchronizer", description="DB synchronizer instance", args={'poll_time': cfg.CONF.datasource_sync_period}) synchronizer = cage.service_object('synchronizer') # add datasource api api_path = os.path.join(src_path, "api/datasource_model.py") LOG.info("main::start() api_path: %s", api_path) cage.loadModule("API-datasource", api_path) cage.createservice(name="api-datasource", moduleName="API-datasource", description="API-datasource DSE instance", args={ 'policy_engine': engine, 'synchronizer': synchronizer }) return cage
def synchronize_rules(self): LOG.debug("Synchronizing rules") # Read rules from DB. cage = d6cage.d6Cage() configured_rules = [{'rule': r.rule, 'id': r.id, 'comment': r.comment, 'name': r.name, 'policy_name': r.policy_name} for r in db_policy_rules.get_policy_rules()] # Read rules from engine engine = cage.service_object('engine') policies = {n: engine.policy_object(n) for n in engine.policy_names()} active_policy_rules = [] for policy_name, policy in policies.items(): if policy.kind != base.DATASOURCE_POLICY_TYPE: for active_rule in policy.content(): active_policy_rules.append( {'rule': active_rule.original_str, 'id': active_rule.id, 'comment': active_rule.comment, 'name': active_rule.name, 'policy_name': policy_name}) # ALEX: the Rule object does not have fields like the rule-string or # id or comment. We can add those fields to the Rule object, as long # as we don't add them to the Fact because there are many fact # instances. If a user tries to create a lot of Rules, they are # probably doing something wrong and should use a datasource driver # instead. changes = [] for r in configured_rules: if r not in active_policy_rules: LOG.debug("adding rule %s", str(r)) parsed_rule = engine.parse1(r['rule']) parsed_rule.set_id(r['id']) parsed_rule.set_name(r['name']) parsed_rule.set_comment(r['comment']) parsed_rule.set_original_str(r['rule']) event = compile.Event(formula=parsed_rule, insert=True, target=r['policy_name']) changes.append(event) for r in active_policy_rules: if r not in configured_rules: LOG.debug("removing rule %s", str(r)) parsed_rule = engine.parse1(r['rule']) parsed_rule.set_id(r['id']) parsed_rule.set_name(r['name']) parsed_rule.set_comment(r['comment']) parsed_rule.set_original_str(r['rule']) event = compile.Event(formula=parsed_rule, insert=False, target=r['policy_name']) changes.append(event) permitted, changes = engine.process_policy_update(changes) LOG.debug("synchronize_rules, permitted %d, made %d changes", permitted, len(changes))